Compare commits

..

16 Commits

Author SHA1 Message Date
Franck Nijhof
4a301eceac Bump version to 2026.3.0b1 2026-02-26 19:32:15 +00:00
Bram Kragten
d138a99e62 Update frontend to 20260226.0 (#164262) 2026-02-26 19:31:52 +00:00
Johnny Willemsen
a431f84dc9 Update state labels to use common keys in compit (#164261) 2026-02-26 19:31:50 +00:00
epenet
aa9534600e Simplify portainer entity initialisation (#164256) 2026-02-26 19:31:49 +00:00
Denis Shulyaka
54fa49e754 Disable code interpreter with minimal reasoning for OpenAI (#164254) 2026-02-26 19:31:47 +00:00
Joost Lekkerkerker
459b6152f4 Remove invalid color mode from philips_js (#164204) 2026-02-26 19:31:46 +00:00
Denis Shulyaka
60c8d997ca Update reasoning options for gpt-5.3-codex (#164179) 2026-02-26 19:31:45 +00:00
AlCalzone
a598368895 Rename "Z-Wave Supervisor app" to "Z-Wave JS app" (#164147) 2026-02-26 19:31:43 +00:00
Erwin Douna
2ff1499c48 Fix stack devices merging with container devices in Portainer (#164135)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-02-26 19:31:42 +00:00
Norbert Rittel
348ddbe124 Replace "add-ons" with "apps" in backup issues (#164129) 2026-02-26 19:31:40 +00:00
Paulus Schoutsen
71ed43faf2 Simplify Anthropic integration name (#164124)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-26 19:31:39 +00:00
mettolen
dc69a90296 Remove error translation placeholders from Saunum (#164121) 2026-02-26 19:31:37 +00:00
Liquidmasl
f5db8e6ba4 Sonarr post merge changes (#164112) 2026-02-26 19:31:36 +00:00
Artur Pragacz
b82a26ef68 Fix Matter vacuum clean area status check (#164108) 2026-02-26 19:31:35 +00:00
Maciej Bieniek
0eaaeedf11 Bump accuweather to 5.1.0 (#164034) 2026-02-26 19:31:33 +00:00
Franck Nijhof
62e26e53ac Bump version to 2026.3.0b0 2026-02-25 19:36:43 +00:00
349 changed files with 9462 additions and 42132 deletions

View File

@@ -34,7 +34,6 @@ base_platforms: &base_platforms
- homeassistant/components/humidifier/**
- homeassistant/components/image/**
- homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/light/**
- homeassistant/components/lock/**

View File

@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 3
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.4"
HA_SHORT_VERSION: "2026.3"
DEFAULT_PYTHON: "3.14.2"
ALL_PYTHON_VERSIONS: "['3.14.2']"
# 10.3 is the oldest supported version
@@ -605,7 +605,7 @@ jobs:
with:
persist-credentials: false
- name: Dependency review
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with:
license-check: false # We use our own license audit checks

View File

@@ -289,7 +289,6 @@ homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.inels.*
homeassistant.components.infrared.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*
@@ -545,7 +544,6 @@ homeassistant.components.tcp.*
homeassistant.components.technove.*
homeassistant.components.tedee.*
homeassistant.components.telegram_bot.*
homeassistant.components.teslemetry.*
homeassistant.components.text.*
homeassistant.components.thethingsnetwork.*
homeassistant.components.threshold.*

8
CODEOWNERS generated
View File

@@ -401,6 +401,8 @@ build.json @home-assistant/supervisor
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/homeassistant/components/duckdns/ @tr4nt0r
/tests/components/duckdns/ @tr4nt0r
/homeassistant/components/duke_energy/ @hunterjm
/tests/components/duke_energy/ @hunterjm
/homeassistant/components/duotecno/ @cereal2nd
/tests/components/duotecno/ @cereal2nd
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192
@@ -792,8 +794,6 @@ build.json @home-assistant/supervisor
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221
/tests/components/influxdb/ @mdegat01 @Robbie1221
/homeassistant/components/infrared/ @home-assistant/core
/tests/components/infrared/ @home-assistant/core
/homeassistant/components/inkbird/ @bdraco
/tests/components/inkbird/ @bdraco
/homeassistant/components/input_boolean/ @home-assistant/core
@@ -1899,8 +1899,8 @@ build.json @home-assistant/supervisor
/tests/components/withings/ @joostlek
/homeassistant/components/wiz/ @sbidy @arturpragacz
/tests/components/wiz/ @sbidy @arturpragacz
/homeassistant/components/wled/ @frenck @mik-laj
/tests/components/wled/ @frenck @mik-laj
/homeassistant/components/wled/ @frenck
/tests/components/wled/ @frenck
/homeassistant/components/wmspro/ @mback2k
/tests/components/wmspro/ @mback2k
/homeassistant/components/wolflink/ @adamkrol93 @mtielen

View File

@@ -10,7 +10,6 @@ coverage:
target: auto
threshold: 1
paths:
- homeassistant/components/*/backup.py
- homeassistant/components/*/config_flow.py
- homeassistant/components/*/device_action.py
- homeassistant/components/*/device_condition.py
@@ -29,7 +28,6 @@ coverage:
target: 100
threshold: 0
paths:
- homeassistant/components/*/backup.py
- homeassistant/components/*/config_flow.py
- homeassistant/components/*/device_action.py
- homeassistant/components/*/device_condition.py

View File

@@ -12,6 +12,10 @@ from homeassistant.helpers.dispatcher import dispatcher_send
from .const import DOMAIN, DOMAIN_DATA, LOGGER
SERVICE_SETTINGS = "change_setting"
SERVICE_CAPTURE_IMAGE = "capture_image"
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
ATTR_SETTING = "setting"
ATTR_VALUE = "value"
@@ -71,13 +75,16 @@ def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
hass.services.async_register(
DOMAIN, "change_setting", _change_setting, schema=CHANGE_SETTING_SCHEMA
DOMAIN, SERVICE_SETTINGS, _change_setting, schema=CHANGE_SETTING_SCHEMA
)
hass.services.async_register(
DOMAIN, "capture_image", _capture_image, schema=CAPTURE_IMAGE_SCHEMA
DOMAIN, SERVICE_CAPTURE_IMAGE, _capture_image, schema=CAPTURE_IMAGE_SCHEMA
)
hass.services.async_register(
DOMAIN, "trigger_automation", _trigger_automation, schema=AUTOMATION_SCHEMA
DOMAIN,
SERVICE_TRIGGER_AUTOMATION,
_trigger_automation,
schema=AUTOMATION_SCHEMA,
)

View File

@@ -10,6 +10,8 @@ from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
ADVANTAGE_AIR_SERVICE_SET_TIME_TO = "set_time_to"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
@@ -18,7 +20,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
service.async_register_platform_entity_service(
hass,
DOMAIN,
"set_time_to",
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
entity_domain=SENSOR_DOMAIN,
schema={vol.Required("minutes"): cv.positive_int},
func="set_time_to",

View File

@@ -8,12 +8,18 @@ from homeassistant.helpers import service
from .const import DOMAIN
_DEV_EN_ALT = "enable_alerts"
_DEV_DS_ALT = "disable_alerts"
_DEV_EN_REC = "start_recording"
_DEV_DS_REC = "stop_recording"
_DEV_SNAP = "snapshot"
CAMERA_SERVICES = {
"enable_alerts": "async_enable_alerts",
"disable_alerts": "async_disable_alerts",
"start_recording": "async_start_recording",
"stop_recording": "async_stop_recording",
"snapshot": "async_snapshot",
_DEV_EN_ALT: "async_enable_alerts",
_DEV_DS_ALT: "async_disable_alerts",
_DEV_EN_REC: "async_start_recording",
_DEV_DS_REC: "async_stop_recording",
_DEV_SNAP: "async_snapshot",
}

View File

@@ -93,6 +93,7 @@ class AirobotNumber(AirobotEntity, NumberEntity):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="set_value_failed",
translation_placeholders={"error": str(err)},
) from err
else:
await self.coordinator.async_request_refresh()

View File

@@ -112,7 +112,7 @@
"message": "Failed to set temperature to {temperature}."
},
"set_value_failed": {
"message": "Failed to set value."
"message": "Failed to set value: {error}"
},
"switch_turn_off_failed": {
"message": "Failed to turn off {switch}."

View File

@@ -1,32 +0,0 @@
"""Diagnostics support for Aladdin Connect."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import AladdinConnectConfigEntry
TO_REDACT = {"access_token", "refresh_token"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
"doors": {
uid: {
"device_id": coordinator.data.device_id,
"door_number": coordinator.data.door_number,
"name": coordinator.data.name,
"status": coordinator.data.status,
"link_status": coordinator.data.link_status,
"battery_level": coordinator.data.battery_level,
}
for uid, coordinator in config_entry.runtime_data.items()
},
}

View File

@@ -45,7 +45,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery: done
discovery-update-info:
status: exempt

View File

@@ -13,6 +13,9 @@ from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
SERVICE_ALARM_TOGGLE_CHIME = "alarm_toggle_chime"
SERVICE_ALARM_KEYPRESS = "alarm_keypress"
ATTR_KEYPRESS = "keypress"
@@ -23,7 +26,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
service.async_register_platform_entity_service(
hass,
DOMAIN,
"alarm_toggle_chime",
SERVICE_ALARM_TOGGLE_CHIME,
entity_domain=ALARM_CONTROL_PANEL_DOMAIN,
schema={
vol.Required(ATTR_CODE): cv.string,
@@ -34,7 +37,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
service.async_register_platform_entity_service(
hass,
DOMAIN,
"alarm_keypress",
SERVICE_ALARM_KEYPRESS,
entity_domain=ALARM_CONTROL_PANEL_DOMAIN,
schema={
vol.Required(ATTR_KEYPRESS): cv.string,

View File

@@ -16,6 +16,9 @@ from .coordinator import AmazonConfigEntry
ATTR_TEXT_COMMAND = "text_command"
ATTR_SOUND = "sound"
ATTR_INFO_SKILL = "info_skill"
SERVICE_TEXT_COMMAND = "send_text_command"
SERVICE_SOUND_NOTIFICATION = "send_sound"
SERVICE_INFO_SKILL = "send_info_skill"
SCHEMA_SOUND_SERVICE = vol.Schema(
{
@@ -125,17 +128,17 @@ def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Amazon Devices integration."""
for service_name, method, schema in (
(
"send_sound",
SERVICE_SOUND_NOTIFICATION,
async_send_sound_notification,
SCHEMA_SOUND_SERVICE,
),
(
"send_text_command",
SERVICE_TEXT_COMMAND,
async_send_text_command,
SCHEMA_CUSTOM_COMMAND,
),
(
"send_info_skill",
SERVICE_INFO_SKILL,
async_send_info_skill,
SCHEMA_INFO_SKILL,
),

View File

@@ -16,6 +16,8 @@ ATTRIBUTION = "Data provided by Amber Electric"
LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
SERVICE_GET_FORECASTS = "get_forecasts"
GENERAL_CHANNEL = "general"
CONTROLLED_LOAD_CHANNEL = "controlled_load"
FEED_IN_CHANNEL = "feed_in"

View File

@@ -22,6 +22,7 @@ from .const import (
DOMAIN,
FEED_IN_CHANNEL,
GENERAL_CHANNEL,
SERVICE_GET_FORECASTS,
)
from .coordinator import AmberConfigEntry
from .helpers import format_cents_to_dollars, normalize_descriptor
@@ -100,7 +101,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
"get_forecasts",
SERVICE_GET_FORECASTS,
handle_get_forecasts,
GET_FORECASTS_SCHEMA,
supports_response=SupportsResponse.ONLY,

View File

@@ -49,6 +49,18 @@ SCAN_INTERVAL = timedelta(seconds=15)
STREAM_SOURCE_LIST = ["snapshot", "mjpeg", "rtsp"]
_SRV_EN_REC = "enable_recording"
_SRV_DS_REC = "disable_recording"
_SRV_EN_AUD = "enable_audio"
_SRV_DS_AUD = "disable_audio"
_SRV_EN_MOT_REC = "enable_motion_recording"
_SRV_DS_MOT_REC = "disable_motion_recording"
_SRV_GOTO = "goto_preset"
_SRV_CBW = "set_color_bw"
_SRV_TOUR_ON = "start_tour"
_SRV_TOUR_OFF = "stop_tour"
_SRV_PTZ_CTRL = "ptz_control"
_ATTR_PTZ_TT = "travel_time"
_ATTR_PTZ_MOV = "movement"
_MOV = [
@@ -91,17 +103,17 @@ _SRV_PTZ_SCHEMA = _SRV_SCHEMA.extend(
)
CAMERA_SERVICES = {
"enable_recording": (_SRV_SCHEMA, "async_enable_recording", ()),
"disable_recording": (_SRV_SCHEMA, "async_disable_recording", ()),
"enable_audio": (_SRV_SCHEMA, "async_enable_audio", ()),
"disable_audio": (_SRV_SCHEMA, "async_disable_audio", ()),
"enable_motion_recording": (_SRV_SCHEMA, "async_enable_motion_recording", ()),
"disable_motion_recording": (_SRV_SCHEMA, "async_disable_motion_recording", ()),
"goto_preset": (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)),
"set_color_bw": (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)),
"start_tour": (_SRV_SCHEMA, "async_start_tour", ()),
"stop_tour": (_SRV_SCHEMA, "async_stop_tour", ()),
"ptz_control": (
_SRV_EN_REC: (_SRV_SCHEMA, "async_enable_recording", ()),
_SRV_DS_REC: (_SRV_SCHEMA, "async_disable_recording", ()),
_SRV_EN_AUD: (_SRV_SCHEMA, "async_enable_audio", ()),
_SRV_DS_AUD: (_SRV_SCHEMA, "async_disable_audio", ()),
_SRV_EN_MOT_REC: (_SRV_SCHEMA, "async_enable_motion_recording", ()),
_SRV_DS_MOT_REC: (_SRV_SCHEMA, "async_disable_motion_recording", ()),
_SRV_GOTO: (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)),
_SRV_CBW: (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)),
_SRV_TOUR_ON: (_SRV_SCHEMA, "async_start_tour", ()),
_SRV_TOUR_OFF: (_SRV_SCHEMA, "async_stop_tour", ()),
_SRV_PTZ_CTRL: (
_SRV_PTZ_SCHEMA,
"async_ptz_control",
(_ATTR_PTZ_MOV, _ATTR_PTZ_TT),

View File

@@ -36,7 +36,7 @@ from .const import (
SIGNAL_CONFIG_ENTITY,
)
from .entity import AndroidTVEntity, adb_decorator
from .services import ATTR_ADB_RESPONSE, ATTR_HDMI_INPUT
from .services import ATTR_ADB_RESPONSE, ATTR_HDMI_INPUT, SERVICE_LEARN_SENDEVENT
_LOGGER = logging.getLogger(__name__)
@@ -271,7 +271,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
self.async_write_ha_state()
msg = (
f"Output from service 'learn_sendevent' from"
f"Output from service '{SERVICE_LEARN_SENDEVENT}' from"
f" {self.entity_id}: '{output}'"
)
persistent_notification.async_create(

View File

@@ -16,6 +16,11 @@ ATTR_DEVICE_PATH = "device_path"
ATTR_HDMI_INPUT = "hdmi_input"
ATTR_LOCAL_PATH = "local_path"
SERVICE_ADB_COMMAND = "adb_command"
SERVICE_DOWNLOAD = "download"
SERVICE_LEARN_SENDEVENT = "learn_sendevent"
SERVICE_UPLOAD = "upload"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
@@ -24,7 +29,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
service.async_register_platform_entity_service(
hass,
DOMAIN,
"adb_command",
SERVICE_ADB_COMMAND,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={vol.Required(ATTR_COMMAND): cv.string},
func="adb_command",
@@ -32,7 +37,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
service.async_register_platform_entity_service(
hass,
DOMAIN,
"learn_sendevent",
SERVICE_LEARN_SENDEVENT,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="learn_sendevent",
@@ -40,7 +45,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
service.async_register_platform_entity_service(
hass,
DOMAIN,
"download",
SERVICE_DOWNLOAD,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={
vol.Required(ATTR_DEVICE_PATH): cv.string,
@@ -51,7 +56,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
service.async_register_platform_entity_service(
hass,
DOMAIN,
"upload",
SERVICE_UPLOAD,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={
vol.Required(ATTR_DEVICE_PATH): cv.string,

View File

@@ -400,8 +400,8 @@ def _convert_content(
# If there is only one text block, simplify the content to a string
messages[-1]["content"] = messages[-1]["content"][0]["text"]
else:
# Note: We don't pass SystemContent here as it's passed to the API as the prompt
raise HomeAssistantError("Unexpected content type in chat log")
# Note: We don't pass SystemContent here as its passed to the API as the prompt
raise TypeError(f"Unexpected content type: {type(content)}")
return messages, container_id
@@ -442,8 +442,8 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
Each message could contain multiple blocks of the same type.
"""
if stream is None or not hasattr(stream, "__aiter__"):
raise HomeAssistantError("Expected a stream of messages")
if stream is None:
raise TypeError("Expected a stream of messages")
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
current_tool_args: str
@@ -456,6 +456,8 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
LOGGER.debug("Received response: %s", response)
if isinstance(response, RawMessageStartEvent):
if response.message.role != "assistant":
raise ValueError("Unexpected message role")
input_usage = response.message.usage
first_block = True
elif isinstance(response, RawContentBlockStartEvent):
@@ -664,7 +666,7 @@ class AnthropicBaseLLMEntity(Entity):
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise HomeAssistantError("First message must be a system message")
raise TypeError("First message must be a system message")
# System prompt with caching enabled
system_prompt: list[TextBlockParam] = [
@@ -856,11 +858,6 @@ class AnthropicBaseLLMEntity(Entity):
]
)
messages.extend(new_messages)
except anthropic.AuthenticationError as err:
self.entry.async_start_reauth(self.hass)
raise HomeAssistantError(
"Authentication error with Anthropic API, reauthentication required"
) from err
except anthropic.AnthropicError as err:
raise HomeAssistantError(
f"Sorry, I had a problem talking to Anthropic: {err}"

View File

@@ -31,7 +31,10 @@ rules:
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
action-exceptions:
status: todo
comment: |
Reevaluate exceptions for entity services.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done

View File

@@ -117,7 +117,6 @@ class SharpAquosTVDevice(MediaPlayerEntity):
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.PLAY
)
_attr_volume_step = 2 / 60
def __init__(
self, name: str, remote: sharp_aquos_rc.TV, power_on_enabled: bool = False
@@ -162,6 +161,22 @@ class SharpAquosTVDevice(MediaPlayerEntity):
"""Turn off tvplayer."""
self._remote.power(0)
@_retry
def volume_up(self) -> None:
"""Volume up the media player."""
if self.volume_level is None:
_LOGGER.debug("Unknown volume in volume_up")
return
self._remote.volume(int(self.volume_level * 60) + 2)
@_retry
def volume_down(self) -> None:
"""Volume down media player."""
if self.volume_level is None:
_LOGGER.debug("Unknown volume in volume_down")
return
self._remote.volume(int(self.volume_level * 60) - 2)
@_retry
def set_volume_level(self, volume: float) -> None:
"""Set Volume media player."""

View File

@@ -19,7 +19,7 @@ from homeassistant.components.backup import (
from homeassistant.core import HomeAssistant, callback
from . import S3ConfigEntry
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .helpers import async_list_backups_from_s3
_LOGGER = logging.getLogger(__name__)
@@ -100,13 +100,6 @@ class S3BackupAgent(BackupAgent):
self.unique_id = entry.entry_id
self._backup_cache: dict[str, AgentBackup] = {}
self._cache_expiration = time()
self._prefix: str = entry.data.get(CONF_PREFIX, "")
def _with_prefix(self, key: str) -> str:
"""Add prefix to a key if configured."""
if not self._prefix:
return key
return f"{self._prefix}/{key}"
@handle_boto_errors
async def async_download_backup(
@@ -122,9 +115,7 @@ class S3BackupAgent(BackupAgent):
backup = await self._find_backup_by_id(backup_id)
tar_filename, _ = suggested_filenames(backup)
response = await self._client.get_object(
Bucket=self._bucket, Key=self._with_prefix(tar_filename)
)
response = await self._client.get_object(Bucket=self._bucket, Key=tar_filename)
return response["Body"].iter_chunks()
async def async_upload_backup(
@@ -151,7 +142,7 @@ class S3BackupAgent(BackupAgent):
metadata_content = json.dumps(backup.as_dict())
await self._client.put_object(
Bucket=self._bucket,
Key=self._with_prefix(metadata_filename),
Key=metadata_filename,
Body=metadata_content,
)
except BotoCoreError as err:
@@ -178,7 +169,7 @@ class S3BackupAgent(BackupAgent):
await self._client.put_object(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
Key=tar_filename,
Body=bytes(file_data),
)
@@ -195,7 +186,7 @@ class S3BackupAgent(BackupAgent):
_LOGGER.debug("Starting multipart upload for %s", tar_filename)
multipart_upload = await self._client.create_multipart_upload(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
Key=tar_filename,
)
upload_id = multipart_upload["UploadId"]
try:
@@ -225,7 +216,7 @@ class S3BackupAgent(BackupAgent):
)
part = await cast(Any, self._client).upload_part(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
Key=tar_filename,
PartNumber=part_number,
UploadId=upload_id,
Body=part_data.tobytes(),
@@ -253,7 +244,7 @@ class S3BackupAgent(BackupAgent):
)
part = await cast(Any, self._client).upload_part(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
Key=tar_filename,
PartNumber=part_number,
UploadId=upload_id,
Body=remaining_data.tobytes(),
@@ -262,7 +253,7 @@ class S3BackupAgent(BackupAgent):
await cast(Any, self._client).complete_multipart_upload(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
Key=tar_filename,
UploadId=upload_id,
MultipartUpload={"Parts": parts},
)
@@ -271,7 +262,7 @@ class S3BackupAgent(BackupAgent):
try:
await self._client.abort_multipart_upload(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
Key=tar_filename,
UploadId=upload_id,
)
except BotoCoreError:
@@ -292,12 +283,8 @@ class S3BackupAgent(BackupAgent):
tar_filename, metadata_filename = suggested_filenames(backup)
# Delete both the backup file and its metadata file
await self._client.delete_object(
Bucket=self._bucket, Key=self._with_prefix(tar_filename)
)
await self._client.delete_object(
Bucket=self._bucket, Key=self._with_prefix(metadata_filename)
)
await self._client.delete_object(Bucket=self._bucket, Key=tar_filename)
await self._client.delete_object(Bucket=self._bucket, Key=metadata_filename)
# Reset cache after successful deletion
self._cache_expiration = time()
@@ -330,9 +317,7 @@ class S3BackupAgent(BackupAgent):
if time() <= self._cache_expiration:
return self._backup_cache
backups_list = await async_list_backups_from_s3(
self._client, self._bucket, self._prefix
)
backups_list = await async_list_backups_from_s3(self._client, self._bucket)
self._backup_cache = {b.backup_id: b for b in backups_list}
self._cache_expiration = time() + CACHE_TTL

View File

@@ -22,7 +22,6 @@ from .const import (
CONF_ACCESS_KEY_ID,
CONF_BUCKET,
CONF_ENDPOINT_URL,
CONF_PREFIX,
CONF_SECRET_ACCESS_KEY,
DEFAULT_ENDPOINT_URL,
DESCRIPTION_AWS_S3_DOCS_URL,
@@ -40,7 +39,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_ENDPOINT_URL, default=DEFAULT_ENDPOINT_URL): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.URL)
),
vol.Optional(CONF_PREFIX, default=""): cv.string,
}
)
@@ -55,17 +53,12 @@ class S3ConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
normalized_prefix = user_input.get(CONF_PREFIX, "").strip("/")
# Check for existing entries, treating missing prefix as empty
for entry in self._async_current_entries(include_ignore=False):
entry_prefix = (entry.data.get(CONF_PREFIX) or "").strip("/")
if (
entry.data.get(CONF_BUCKET) == user_input[CONF_BUCKET]
and entry.data.get(CONF_ENDPOINT_URL)
== user_input[CONF_ENDPOINT_URL]
and entry_prefix == normalized_prefix
):
return self.async_abort(reason="already_configured")
self._async_abort_entries_match(
{
CONF_BUCKET: user_input[CONF_BUCKET],
CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL],
}
)
hostname = urlparse(user_input[CONF_ENDPOINT_URL]).hostname
if not hostname or not hostname.endswith(AWS_DOMAIN):
@@ -90,18 +83,9 @@ class S3ConfigFlow(ConfigFlow, domain=DOMAIN):
except ConnectionError:
errors[CONF_ENDPOINT_URL] = "cannot_connect"
else:
data = dict(user_input)
if not normalized_prefix:
# Do not persist empty optional values
data.pop(CONF_PREFIX, None)
else:
data[CONF_PREFIX] = normalized_prefix
title = user_input[CONF_BUCKET]
if normalized_prefix:
title = f"{title} - {normalized_prefix}"
return self.async_create_entry(title=title, data=data)
return self.async_create_entry(
title=user_input[CONF_BUCKET], data=user_input
)
return self.async_show_form(
step_id="user",

View File

@@ -11,7 +11,6 @@ CONF_ACCESS_KEY_ID = "access_key_id"
CONF_SECRET_ACCESS_KEY = "secret_access_key"
CONF_ENDPOINT_URL = "endpoint_url"
CONF_BUCKET = "bucket"
CONF_PREFIX = "prefix"
AWS_DOMAIN = "amazonaws.com"
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"

View File

@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_BUCKET, CONF_PREFIX, DOMAIN
from .const import CONF_BUCKET, DOMAIN
from .helpers import async_list_backups_from_s3
SCAN_INTERVAL = timedelta(hours=6)
@@ -53,14 +53,11 @@ class S3DataUpdateCoordinator(DataUpdateCoordinator[SensorData]):
)
self.client = client
self._bucket: str = entry.data[CONF_BUCKET]
self._prefix: str = entry.data.get(CONF_PREFIX, "")
async def _async_update_data(self) -> SensorData:
"""Fetch data from AWS S3."""
try:
backups = await async_list_backups_from_s3(
self.client, self._bucket, self._prefix
)
backups = await async_list_backups_from_s3(self.client, self._bucket)
except BotoCoreError as error:
raise UpdateFailed(
translation_domain=DOMAIN,

View File

@@ -1,55 +0,0 @@
"""Diagnostics support for AWS S3."""
from __future__ import annotations
import dataclasses
from typing import Any
from homeassistant.components.backup import (
DATA_MANAGER as BACKUP_DATA_MANAGER,
BackupManager,
)
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .const import (
CONF_ACCESS_KEY_ID,
CONF_BUCKET,
CONF_PREFIX,
CONF_SECRET_ACCESS_KEY,
DOMAIN,
)
from .coordinator import S3ConfigEntry
from .helpers import async_list_backups_from_s3
TO_REDACT = (CONF_ACCESS_KEY_ID, CONF_SECRET_ACCESS_KEY)
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: S3ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
backup_manager: BackupManager = hass.data[BACKUP_DATA_MANAGER]
backups = await async_list_backups_from_s3(
coordinator.client,
bucket=entry.data[CONF_BUCKET],
prefix=entry.data.get(CONF_PREFIX, ""),
)
data = {
"coordinator_data": dataclasses.asdict(coordinator.data),
"config": {
**entry.data,
**entry.options,
},
"backup_agents": [
{"name": agent.name}
for agent in backup_manager.backup_agents.values()
if agent.domain == DOMAIN
],
"backup": [backup.as_dict() for backup in backups],
}
return async_redact_data(data, TO_REDACT)

View File

@@ -17,17 +17,11 @@ _LOGGER = logging.getLogger(__name__)
async def async_list_backups_from_s3(
client: S3Client,
bucket: str,
prefix: str,
) -> list[AgentBackup]:
"""List backups from an S3 bucket by reading metadata files."""
paginator = client.get_paginator("list_objects_v2")
metadata_files: list[dict[str, Any]] = []
list_kwargs: dict[str, Any] = {"Bucket": bucket}
if prefix:
list_kwargs["Prefix"] = prefix + "/"
async for page in paginator.paginate(**list_kwargs):
async for page in paginator.paginate(Bucket=bucket):
metadata_files.extend(
obj
for obj in page.get("Contents", [])

View File

@@ -23,9 +23,7 @@ rules:
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry:
status: exempt
comment: Hassfest does not recognize the duplicate prevention logic. Duplicate entries are prevented by checking bucket, endpoint URL, and prefix in the config flow.
unique-config-entry: done
# Silver
action-exceptions:
@@ -38,14 +36,14 @@ rules:
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: S3 is a cloud service that is not discovered on the network.

View File

@@ -15,14 +15,12 @@
"access_key_id": "Access key ID",
"bucket": "Bucket name",
"endpoint_url": "Endpoint URL",
"prefix": "Prefix",
"secret_access_key": "Secret access key"
},
"data_description": {
"access_key_id": "Access key ID to connect to AWS S3 API",
"bucket": "Bucket must already exist and be writable by the provided credentials.",
"endpoint_url": "Endpoint URL provided to [Boto3 Session]({boto3_docs_url}). Region-specific [AWS S3 endpoints]({aws_s3_docs_url}) are available in their docs.",
"prefix": "Folder or prefix to store backups in, for example `backups`",
"secret_access_key": "Secret access key to connect to AWS S3 API"
},
"title": "Add AWS S3 bucket"

View File

@@ -85,7 +85,6 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
_attr_media_content_type = MediaType.MUSIC
_attr_has_entity_name = True
_attr_name = None
_attr_volume_step = 0.01
def __init__(
self,
@@ -689,6 +688,24 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
await self._player.play_url(url)
async def async_volume_up(self) -> None:
"""Volume up the media player."""
if self.volume_level is None:
return
new_volume = self.volume_level + 0.01
new_volume = min(1, new_volume)
await self.async_set_volume_level(new_volume)
async def async_volume_down(self) -> None:
"""Volume down the media player."""
if self.volume_level is None:
return
new_volume = self.volume_level - 0.01
new_volume = max(0, new_volume)
await self.async_set_volume_level(new_volume)
async def async_set_volume_level(self, volume: float) -> None:
"""Send volume_up command to media player."""
volume = int(round(volume * 100))

View File

@@ -64,8 +64,6 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=0,
entity_registry_enabled_default=False,
value_fn=lambda data: (
data.sensor.total_energy.value
if data.sensor.total_energy is not None

View File

@@ -31,6 +31,10 @@ ATTR_FRIDAY_SLOTS = "friday_slots"
ATTR_SATURDAY_SLOTS = "saturday_slots"
ATTR_SUNDAY_SLOTS = "sunday_slots"
# Service names
SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule"
SERVICE_SYNC_TIME = "sync_time"
# Schema for a single time slot
_SLOT_SCHEMA = vol.Schema(
@@ -256,14 +260,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
"""Register the BSB-LAN services."""
hass.services.async_register(
DOMAIN,
"set_hot_water_schedule",
SERVICE_SET_HOT_WATER_SCHEDULE,
set_hot_water_schedule,
schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
"sync_time",
SERVICE_SYNC_TIME,
async_sync_time,
schema=SYNC_TIME_SCHEMA,
)

View File

@@ -807,7 +807,6 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
# The lovelace app loops media to prevent timing out, don't show that
if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE:
return MediaPlayerState.PLAYING
if (media_status := self._media_status()[0]) is not None:
if media_status.player_state == MEDIA_PLAYER_STATE_PLAYING:
return MediaPlayerState.PLAYING
@@ -818,19 +817,19 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
if media_status.player_is_idle:
return MediaPlayerState.IDLE
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
# Some apps don't report media status, show the player as playing
return MediaPlayerState.PLAYING
if self.app_id is not None and self.app_id != pychromecast.config.APP_BACKDROP:
# We have an active app
return MediaPlayerState.IDLE
if self._chromecast is not None and self._chromecast.is_idle:
# If library consider us idle, that is our off state
# it takes HDMI status into account for cast devices.
return MediaPlayerState.OFF
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
# Some apps don't report media status, show the player as playing
return MediaPlayerState.PLAYING
if self.app_id is not None:
# We have an active app
return MediaPlayerState.IDLE
return None
@property

View File

@@ -12,7 +12,6 @@ from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.FAN,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,

View File

@@ -1,172 +0,0 @@
"""Fan platform for Compit integration."""
from typing import Any
from compit_inext_api import PARAM_VALUES
from compit_inext_api.consts import CompitParameter
from homeassistant.components.fan import (
FanEntity,
FanEntityDescription,
FanEntityFeature,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
)
from .const import DOMAIN, MANUFACTURER_NAME
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PARALLEL_UPDATES = 0
COMPIT_GEAR_TO_HA = PARAM_VALUES[CompitParameter.VENTILATION_GEAR_TARGET]
HA_STATE_TO_COMPIT = {value: key for key, value in COMPIT_GEAR_TO_HA.items()}
DEVICE_DEFINITIONS: dict[int, FanEntityDescription] = {
223: FanEntityDescription(
key="Nano Color 2",
translation_key="ventilation",
),
12: FanEntityDescription(
key="Nano Color",
translation_key="ventilation",
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: CompitConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Compit fan entities from a config entry."""
coordinator = entry.runtime_data
async_add_entities(
CompitFan(
coordinator,
device_id,
device_definition,
)
for device_id, device in coordinator.connector.all_devices.items()
if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code))
)
class CompitFan(CoordinatorEntity[CompitDataUpdateCoordinator], FanEntity):
"""Representation of a Compit fan entity."""
_attr_speed_count = len(COMPIT_GEAR_TO_HA)
_attr_has_entity_name = True
_attr_name = None
_attr_supported_features = (
FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
| FanEntityFeature.SET_SPEED
)
def __init__(
self,
coordinator: CompitDataUpdateCoordinator,
device_id: int,
entity_description: FanEntityDescription,
) -> None:
"""Initialize the fan entity."""
super().__init__(coordinator)
self.device_id = device_id
self.entity_description = entity_description
self._attr_unique_id = f"{device_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(device_id))},
name=entity_description.key,
manufacturer=MANUFACTURER_NAME,
model=entity_description.key,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.coordinator.connector.get_device(self.device_id) is not None
)
@property
def is_on(self) -> bool | None:
"""Return true if the fan is on."""
value = self.coordinator.connector.get_current_option(
self.device_id, CompitParameter.VENTILATION_ON_OFF
)
return True if value == STATE_ON else False if value == STATE_OFF else None
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
await self.coordinator.connector.select_device_option(
self.device_id, CompitParameter.VENTILATION_ON_OFF, STATE_ON
)
if percentage is None:
self.async_write_ha_state()
return
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
await self.coordinator.connector.select_device_option(
self.device_id, CompitParameter.VENTILATION_ON_OFF, STATE_OFF
)
self.async_write_ha_state()
@property
def percentage(self) -> int | None:
"""Return the current fan speed as a percentage."""
if self.is_on is False:
return 0
mode = self.coordinator.connector.get_current_option(
self.device_id, CompitParameter.VENTILATION_GEAR_TARGET
)
if mode is None:
return None
gear = COMPIT_GEAR_TO_HA.get(mode)
return (
None
if gear is None
else ordered_list_item_to_percentage(
list(COMPIT_GEAR_TO_HA.values()),
gear,
)
)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the fan speed."""
if percentage == 0:
await self.async_turn_off()
return
gear = int(
percentage_to_ordered_list_item(
list(COMPIT_GEAR_TO_HA.values()),
percentage,
)
)
mode = HA_STATE_TO_COMPIT.get(gear)
if mode is None:
return
await self.coordinator.connector.select_device_option(
self.device_id, CompitParameter.VENTILATION_GEAR_TARGET, mode
)
self.async_write_ha_state()

View File

@@ -20,14 +20,6 @@
"default": "mdi:alert"
}
},
"fan": {
"ventilation": {
"default": "mdi:fan",
"state": {
"off": "mdi:fan-off"
}
}
},
"number": {
"boiler_target_temperature": {
"default": "mdi:water-boiler"

View File

@@ -53,11 +53,6 @@
"name": "Temperature alert"
}
},
"fan": {
"ventilation": {
"name": "[%key:component::fan::title%]"
}
},
"number": {
"boiler_target_temperature": {
"name": "Boiler target temperature"

View File

@@ -115,7 +115,7 @@ def _zone_temperature_lists(device: Appliance) -> tuple[list[str], list[str]]:
try:
heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1]
cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1]
except AttributeError, KeyError:
except AttributeError:
return ([], [])
return (list(heating or []), list(cooling or []))

View File

@@ -139,6 +139,18 @@ class AbstractDemoPlayer(MediaPlayerEntity):
self._attr_is_volume_muted = mute
self.schedule_update_ha_state()
def volume_up(self) -> None:
"""Increase volume."""
assert self.volume_level is not None
self._attr_volume_level = min(1.0, self.volume_level + 0.1)
self.schedule_update_ha_state()
def volume_down(self) -> None:
"""Decrease volume."""
assert self.volume_level is not None
self._attr_volume_level = max(0.0, self.volume_level - 0.1)
self.schedule_update_ha_state()
def set_volume_level(self, volume: float) -> None:
"""Set the volume level, range 0..1."""
self._attr_volume_level = volume

View File

@@ -0,0 +1,22 @@
"""The Duke Energy integration."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .coordinator import DukeEnergyConfigEntry, DukeEnergyCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool:
"""Set up Duke Energy from a config entry."""
coordinator = DukeEnergyCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
return True
async def async_unload_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool:
"""Unload a config entry."""
return True

View File

@@ -0,0 +1,67 @@
"""Config flow for Duke Energy integration."""
from __future__ import annotations
import logging
from typing import Any
from aiodukeenergy import DukeEnergy
from aiohttp import ClientError, ClientResponseError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class DukeEnergyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Duke Energy."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
session = async_get_clientsession(self.hass)
api = DukeEnergy(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session
)
try:
auth = await api.authenticate()
except ClientResponseError as e:
errors["base"] = "invalid_auth" if e.status == 404 else "cannot_connect"
except ClientError, TimeoutError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
username = auth["internalUserID"].lower()
await self.async_set_unique_id(username)
self._abort_if_unique_id_configured()
email = auth["loginEmailAddress"].lower()
data = {
CONF_EMAIL: email,
CONF_USERNAME: username,
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
self._async_abort_entries_match(data)
return self.async_create_entry(title=email, data=data)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -0,0 +1,3 @@
"""Constants for the Duke Energy integration."""
DOMAIN = "duke_energy"

View File

@@ -0,0 +1,222 @@
"""Coordinator to handle Duke Energy connections."""
from datetime import datetime, timedelta
import logging
from typing import Any, cast
from aiodukeenergy import DukeEnergy
from aiohttp import ClientError
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
statistics_during_period,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_SUPPORTED_METER_TYPES = ("ELECTRIC",)
type DukeEnergyConfigEntry = ConfigEntry[DukeEnergyCoordinator]
class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
"""Handle inserting statistics."""
config_entry: DukeEnergyConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: DukeEnergyConfigEntry
) -> None:
"""Initialize the data handler."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name="Duke Energy",
# Data is updated daily on Duke Energy.
# Refresh every 12h to be at most 12h behind.
update_interval=timedelta(hours=12),
)
self.api = DukeEnergy(
config_entry.data[CONF_USERNAME],
config_entry.data[CONF_PASSWORD],
async_get_clientsession(hass),
)
self._statistic_ids: set = set()
@callback
def _dummy_listener() -> None:
pass
# Force the coordinator to periodically update by registering at least one listener.
# Duke Energy does not provide forecast data, so all information is historical.
# This makes _async_update_data get periodically called so we can insert statistics.
self.async_add_listener(_dummy_listener)
self.config_entry.async_on_unload(self._clear_statistics)
def _clear_statistics(self) -> None:
"""Clear statistics."""
get_instance(self.hass).async_clear_statistics(list(self._statistic_ids))
async def _async_update_data(self) -> None:
"""Insert Duke Energy statistics."""
meters: dict[str, dict[str, Any]] = await self.api.get_meters()
for serial_number, meter in meters.items():
if (
not isinstance(meter["serviceType"], str)
or meter["serviceType"] not in _SUPPORTED_METER_TYPES
):
_LOGGER.debug(
"Skipping unsupported meter type %s", meter["serviceType"]
)
continue
id_prefix = f"{meter['serviceType'].lower()}_{serial_number}"
consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption"
self._statistic_ids.add(consumption_statistic_id)
_LOGGER.debug(
"Updating Statistics for %s",
consumption_statistic_id,
)
last_stat = await get_instance(self.hass).async_add_executor_job(
get_last_statistics, self.hass, 1, consumption_statistic_id, True, set()
)
if not last_stat:
_LOGGER.debug("Updating statistic for the first time")
usage = await self._async_get_energy_usage(meter)
consumption_sum = 0.0
last_stats_time = None
else:
usage = await self._async_get_energy_usage(
meter,
last_stat[consumption_statistic_id][0]["start"],
)
if not usage:
_LOGGER.debug("No recent usage data. Skipping update")
continue
stats = await get_instance(self.hass).async_add_executor_job(
statistics_during_period,
self.hass,
min(usage.keys()),
None,
{consumption_statistic_id},
"hour",
None,
{"sum"},
)
consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"])
last_stats_time = stats[consumption_statistic_id][0]["start"]
consumption_statistics = []
for start, data in usage.items():
if last_stats_time is not None and start.timestamp() <= last_stats_time:
continue
consumption_sum += data["energy"]
consumption_statistics.append(
StatisticData(
start=start, state=data["energy"], sum=consumption_sum
)
)
name_prefix = (
f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}"
)
consumption_metadata = StatisticMetaData(
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} Consumption",
source=DOMAIN,
statistic_id=consumption_statistic_id,
unit_class=EnergyConverter.UNIT_CLASS,
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR
if meter["serviceType"] == "ELECTRIC"
else UnitOfVolume.CENTUM_CUBIC_FEET,
)
_LOGGER.debug(
"Adding %s statistics for %s",
len(consumption_statistics),
consumption_statistic_id,
)
async_add_external_statistics(
self.hass, consumption_metadata, consumption_statistics
)
async def _async_get_energy_usage(
self, meter: dict[str, Any], start_time: float | None = None
) -> dict[datetime, dict[str, float | int]]:
"""Get energy usage.
If start_time is None, get usage since account activation (or as far back as possible),
otherwise since start_time - 30 days to allow corrections in data.
Duke Energy provides hourly data all the way back to ~3 years.
"""
# All of Duke Energy Service Areas are currently in America/New_York timezone
# May need to re-think this if that ever changes and determine timezone based
# on the service address somehow.
tz = await dt_util.async_get_time_zone("America/New_York")
lookback = timedelta(days=30)
one = timedelta(days=1)
if start_time is None:
# Max 3 years of data
start = dt_util.now(tz) - timedelta(days=3 * 365)
else:
start = datetime.fromtimestamp(start_time, tz=tz) - lookback
agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"])
if agreement_date is not None:
start = max(agreement_date.replace(tzinfo=tz), start)
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one
_LOGGER.debug("Data lookup range: %s - %s", start, end)
start_step = max(end - lookback, start)
end_step = end
usage: dict[datetime, dict[str, float | int]] = {}
while True:
_LOGGER.debug("Getting hourly usage: %s - %s", start_step, end_step)
try:
# Get data
results = await self.api.get_energy_usage(
meter["serialNum"], "HOURLY", "DAY", start_step, end_step
)
usage = {**results["data"], **usage}
for missing in results["missing"]:
_LOGGER.debug("Missing data: %s", missing)
# Set next range
end_step = start_step - one
start_step = max(start_step - lookback, start)
# Make sure we don't go back too far
if end_step < start:
break
except TimeoutError, ClientError:
# ClientError is raised when there is no more data for the range
break
_LOGGER.debug("Got %s meter usage reads", len(usage))
return usage

View File

@@ -0,0 +1,11 @@
{
"domain": "duke_energy",
"name": "Duke Energy",
"codeowners": ["@hunterjm"],
"config_flow": true,
"dependencies": ["recorder"],
"documentation": "https://www.home-assistant.io/integrations/duke_energy",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["aiodukeenergy==0.3.0"]
}

View File

@@ -0,0 +1,20 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
}
}
}
}
}

View File

@@ -33,8 +33,6 @@ DUNEHD_PLAYER_SUPPORT: Final[MediaPlayerEntityFeature] = (
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
)

View File

@@ -405,13 +405,8 @@ CT_SENSORS = (
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "lifetime_net_consumption"),
(CtType.PRODUCTION, "production_ct_energy_delivered"),
# Production CT energy_delivered is not used
(CtType.STORAGE, "lifetime_battery_discharged"),
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_energy_delivered"),
(CtType.BACKFEED, "backfeed_ct_energy_delivered"),
(CtType.LOAD, "load_ct_energy_delivered"),
(CtType.EVSE, "evse_ct_energy_delivered"),
(CtType.PV3P, "pv3p_ct_energy_delivered"),
)
]
+ [
@@ -428,13 +423,8 @@ CT_SENSORS = (
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "lifetime_net_production"),
(CtType.PRODUCTION, "production_ct_energy_received"),
# Production CT energy_received is not used
(CtType.STORAGE, "lifetime_battery_charged"),
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_energy_received"),
(CtType.BACKFEED, "backfeed_ct_energy_received"),
(CtType.LOAD, "load_ct_energy_received"),
(CtType.EVSE, "evse_ct_energy_received"),
(CtType.PV3P, "pv3p_ct_energy_received"),
)
]
+ [
@@ -451,13 +441,8 @@ CT_SENSORS = (
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "net_consumption"),
(CtType.PRODUCTION, "production_ct_power"),
# Production CT active_power is not used
(CtType.STORAGE, "battery_discharge"),
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_power"),
(CtType.BACKFEED, "backfeed_ct_power"),
(CtType.LOAD, "load_ct_power"),
(CtType.EVSE, "evse_ct_power"),
(CtType.PV3P, "pv3p_ct_power"),
)
]
+ [
@@ -476,11 +461,6 @@ CT_SENSORS = (
(CtType.NET_CONSUMPTION, "frequency", "net_ct_frequency"),
(CtType.PRODUCTION, "production_ct_frequency", ""),
(CtType.STORAGE, "storage_ct_frequency", ""),
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_frequency", ""),
(CtType.BACKFEED, "backfeed_ct_frequency", ""),
(CtType.LOAD, "load_ct_frequency", ""),
(CtType.EVSE, "evse_ct_frequency", ""),
(CtType.PV3P, "pv3p_ct_frequency", ""),
)
]
+ [
@@ -500,11 +480,6 @@ CT_SENSORS = (
(CtType.NET_CONSUMPTION, "voltage", "net_ct_voltage"),
(CtType.PRODUCTION, "production_ct_voltage", ""),
(CtType.STORAGE, "storage_voltage", "storage_ct_voltage"),
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_voltage", ""),
(CtType.BACKFEED, "backfeed_ct_voltage", ""),
(CtType.LOAD, "load_ct_voltage", ""),
(CtType.EVSE, "evse_ct_voltage", ""),
(CtType.PV3P, "pv3p_ct_voltage", ""),
)
]
+ [
@@ -524,11 +499,6 @@ CT_SENSORS = (
(CtType.NET_CONSUMPTION, "net_ct_current"),
(CtType.PRODUCTION, "production_ct_current"),
(CtType.STORAGE, "storage_ct_current"),
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_current"),
(CtType.BACKFEED, "backfeed_ct_current"),
(CtType.LOAD, "load_ct_current"),
(CtType.EVSE, "evse_ct_current"),
(CtType.PV3P, "pv3p_ct_current"),
)
]
+ [
@@ -546,11 +516,6 @@ CT_SENSORS = (
(CtType.NET_CONSUMPTION, "net_ct_powerfactor"),
(CtType.PRODUCTION, "production_ct_powerfactor"),
(CtType.STORAGE, "storage_ct_powerfactor"),
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_powerfactor"),
(CtType.BACKFEED, "backfeed_ct_powerfactor"),
(CtType.LOAD, "load_ct_powerfactor"),
(CtType.EVSE, "evse_ct_powerfactor"),
(CtType.PV3P, "pv3p_ct_powerfactor"),
)
]
+ [
@@ -572,11 +537,6 @@ CT_SENSORS = (
),
(CtType.PRODUCTION, "production_ct_metering_status", ""),
(CtType.STORAGE, "storage_ct_metering_status", ""),
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_metering_status", ""),
(CtType.BACKFEED, "backfeed_ct_metering_status", ""),
(CtType.LOAD, "load_ct_metering_status", ""),
(CtType.EVSE, "evse_ct_metering_status", ""),
(CtType.PV3P, "pv3p_ct_metering_status", ""),
)
]
+ [
@@ -597,11 +557,6 @@ CT_SENSORS = (
),
(CtType.PRODUCTION, "production_ct_status_flags", ""),
(CtType.STORAGE, "storage_ct_status_flags", ""),
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_status_flags", ""),
(CtType.BACKFEED, "backfeed_ct_status_flags", ""),
(CtType.LOAD, "load_ct_status_flags", ""),
(CtType.EVSE, "evse_ct_status_flags", ""),
(CtType.PV3P, "pv3p_ct_status_flags", ""),
)
]
)

View File

@@ -160,60 +160,6 @@
"available_energy": {
"name": "Available battery energy"
},
"backfeed_ct_current": {
"name": "Backfeed CT current"
},
"backfeed_ct_current_phase": {
"name": "Backfeed CT current {phase_name}"
},
"backfeed_ct_energy_delivered": {
"name": "Backfeed CT energy delivered"
},
"backfeed_ct_energy_delivered_phase": {
"name": "Backfeed CT energy delivered {phase_name}"
},
"backfeed_ct_energy_received": {
"name": "Backfeed CT energy received"
},
"backfeed_ct_energy_received_phase": {
"name": "Backfeed CT energy received {phase_name}"
},
"backfeed_ct_frequency": {
"name": "Frequency backfeed CT"
},
"backfeed_ct_frequency_phase": {
"name": "Frequency backfeed CT {phase_name}"
},
"backfeed_ct_metering_status": {
"name": "Metering status backfeed CT"
},
"backfeed_ct_metering_status_phase": {
"name": "Metering status backfeed CT {phase_name}"
},
"backfeed_ct_power": {
"name": "Backfeed CT power"
},
"backfeed_ct_power_phase": {
"name": "Backfeed CT power {phase_name}"
},
"backfeed_ct_powerfactor": {
"name": "Power factor backfeed CT"
},
"backfeed_ct_powerfactor_phase": {
"name": "Power factor backfeed CT {phase_name}"
},
"backfeed_ct_status_flags": {
"name": "Meter status flags active backfeed CT"
},
"backfeed_ct_status_flags_phase": {
"name": "Meter status flags active backfeed CT {phase_name}"
},
"backfeed_ct_voltage": {
"name": "Voltage backfeed CT"
},
"backfeed_ct_voltage_phase": {
"name": "Voltage backfeed CT {phase_name}"
},
"balanced_net_consumption": {
"name": "Balanced net power consumption"
},
@@ -265,60 +211,6 @@
"energy_today": {
"name": "[%key:component::enphase_envoy::entity::sensor::daily_production::name%]"
},
"evse_ct_current": {
"name": "EVSE CT current"
},
"evse_ct_current_phase": {
"name": "EVSE CT current {phase_name}"
},
"evse_ct_energy_delivered": {
"name": "EVSE CT energy delivered"
},
"evse_ct_energy_delivered_phase": {
"name": "EVSE CT energy delivered {phase_name}"
},
"evse_ct_energy_received": {
"name": "EVSE CT energy received"
},
"evse_ct_energy_received_phase": {
"name": "EVSE CT energy received {phase_name}"
},
"evse_ct_frequency": {
"name": "Frequency EVSE CT"
},
"evse_ct_frequency_phase": {
"name": "Frequency EVSE CT {phase_name}"
},
"evse_ct_metering_status": {
"name": "Metering status EVSE CT"
},
"evse_ct_metering_status_phase": {
"name": "Metering status EVSE CT {phase_name}"
},
"evse_ct_power": {
"name": "EVSE CT power"
},
"evse_ct_power_phase": {
"name": "EVSE CT power {phase_name}"
},
"evse_ct_powerfactor": {
"name": "Power factor EVSE CT"
},
"evse_ct_powerfactor_phase": {
"name": "Power factor EVSE CT {phase_name}"
},
"evse_ct_status_flags": {
"name": "Meter status flags active EVSE CT"
},
"evse_ct_status_flags_phase": {
"name": "Meter status flags active EVSE CT {phase_name}"
},
"evse_ct_voltage": {
"name": "Voltage EVSE CT"
},
"evse_ct_voltage_phase": {
"name": "Voltage EVSE CT {phase_name}"
},
"grid_status": {
"name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]",
"state": {
@@ -378,60 +270,6 @@
"lifetime_production_phase": {
"name": "Lifetime energy production {phase_name}"
},
"load_ct_current": {
"name": "Load CT current"
},
"load_ct_current_phase": {
"name": "Load CT current {phase_name}"
},
"load_ct_energy_delivered": {
"name": "Load CT energy delivered"
},
"load_ct_energy_delivered_phase": {
"name": "Load CT energy delivered {phase_name}"
},
"load_ct_energy_received": {
"name": "Load CT energy received"
},
"load_ct_energy_received_phase": {
"name": "Load CT energy received {phase_name}"
},
"load_ct_frequency": {
"name": "Frequency load CT"
},
"load_ct_frequency_phase": {
"name": "Frequency load CT {phase_name}"
},
"load_ct_metering_status": {
"name": "Metering status load CT"
},
"load_ct_metering_status_phase": {
"name": "Metering status load CT {phase_name}"
},
"load_ct_power": {
"name": "Load CT power"
},
"load_ct_power_phase": {
"name": "Load CT power {phase_name}"
},
"load_ct_powerfactor": {
"name": "Power factor load CT"
},
"load_ct_powerfactor_phase": {
"name": "Power factor load CT {phase_name}"
},
"load_ct_status_flags": {
"name": "Meter status flags active load CT"
},
"load_ct_status_flags_phase": {
"name": "Meter status flags active load CT {phase_name}"
},
"load_ct_voltage": {
"name": "Voltage load CT"
},
"load_ct_voltage_phase": {
"name": "Voltage load CT {phase_name}"
},
"max_capacity": {
"name": "Battery capacity"
},
@@ -493,18 +331,6 @@
"production_ct_current_phase": {
"name": "Production CT current {phase_name}"
},
"production_ct_energy_delivered": {
"name": "Production CT energy delivered"
},
"production_ct_energy_delivered_phase": {
"name": "Production CT energy delivered {phase_name}"
},
"production_ct_energy_received": {
"name": "Production CT energy received"
},
"production_ct_energy_received_phase": {
"name": "Production CT energy received {phase_name}"
},
"production_ct_frequency": {
"name": "Frequency production CT"
},
@@ -517,12 +343,6 @@
"production_ct_metering_status_phase": {
"name": "Metering status production CT {phase_name}"
},
"production_ct_power": {
"name": "Production CT power"
},
"production_ct_power_phase": {
"name": "Production CT power {phase_name}"
},
"production_ct_powerfactor": {
"name": "Power factor production CT"
},
@@ -541,60 +361,6 @@
"production_ct_voltage_phase": {
"name": "Voltage production CT {phase_name}"
},
"pv3p_ct_current": {
"name": "PV3P CT current"
},
"pv3p_ct_current_phase": {
"name": "PV3P CT current {phase_name}"
},
"pv3p_ct_energy_delivered": {
"name": "PV3P CT energy delivered"
},
"pv3p_ct_energy_delivered_phase": {
"name": "PV3P CT energy delivered {phase_name}"
},
"pv3p_ct_energy_received": {
"name": "PV3P CT energy received"
},
"pv3p_ct_energy_received_phase": {
"name": "PV3P CT energy received {phase_name}"
},
"pv3p_ct_frequency": {
"name": "Frequency PV3P CT"
},
"pv3p_ct_frequency_phase": {
"name": "Frequency PV3P CT {phase_name}"
},
"pv3p_ct_metering_status": {
"name": "Metering status PV3P CT"
},
"pv3p_ct_metering_status_phase": {
"name": "Metering status PV3P CT {phase_name}"
},
"pv3p_ct_power": {
"name": "PV3P CT power"
},
"pv3p_ct_power_phase": {
"name": "PV3P CT power {phase_name}"
},
"pv3p_ct_powerfactor": {
"name": "Power factor PV3P CT"
},
"pv3p_ct_powerfactor_phase": {
"name": "Power factor PV3P CT {phase_name}"
},
"pv3p_ct_status_flags": {
"name": "Meter status flags active PV3P CT"
},
"pv3p_ct_status_flags_phase": {
"name": "Meter status flags active PV3P CT {phase_name}"
},
"pv3p_ct_voltage": {
"name": "Voltage PV3P CT"
},
"pv3p_ct_voltage_phase": {
"name": "Voltage PV3P CT {phase_name}"
},
"reserve_energy": {
"name": "Reserve battery energy"
},
@@ -648,60 +414,6 @@
},
"storage_ct_voltage_phase": {
"name": "Voltage storage CT {phase_name}"
},
"total_consumption_ct_current": {
"name": "Total consumption CT current"
},
"total_consumption_ct_current_phase": {
"name": "Total consumption CT current {phase_name}"
},
"total_consumption_ct_energy_delivered": {
"name": "Total consumption CT energy delivered"
},
"total_consumption_ct_energy_delivered_phase": {
"name": "Total consumption CT energy delivered {phase_name}"
},
"total_consumption_ct_energy_received": {
"name": "Total consumption CT energy received"
},
"total_consumption_ct_energy_received_phase": {
"name": "Total consumption CT energy received {phase_name}"
},
"total_consumption_ct_frequency": {
"name": "Frequency total consumption CT"
},
"total_consumption_ct_frequency_phase": {
"name": "Frequency total consumption CT {phase_name}"
},
"total_consumption_ct_metering_status": {
"name": "Metering status total consumption CT"
},
"total_consumption_ct_metering_status_phase": {
"name": "Metering status total consumption CT {phase_name}"
},
"total_consumption_ct_power": {
"name": "Total consumption CT power"
},
"total_consumption_ct_power_phase": {
"name": "Total consumption CT power {phase_name}"
},
"total_consumption_ct_powerfactor": {
"name": "Power factor total consumption CT"
},
"total_consumption_ct_powerfactor_phase": {
"name": "Power factor total consumption CT {phase_name}"
},
"total_consumption_ct_status_flags": {
"name": "Meter status flags active total consumption CT"
},
"total_consumption_ct_status_flags_phase": {
"name": "Meter status flags active total consumption CT {phase_name}"
},
"total_consumption_ct_voltage": {
"name": "Voltage total consumption CT"
},
"total_consumption_ct_voltage_phase": {
"name": "Voltage total consumption CT {phase_name}"
}
},
"switch": {

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.13.2"]
"requirements": ["env-canada==0.12.4"]
}

View File

@@ -36,12 +36,19 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from .const import ATTR_DURATION, ATTR_PERIOD, DOMAIN, EVOHOME_DATA, EvoService
from .const import (
ATTR_DURATION,
ATTR_DURATION_UNTIL,
ATTR_PERIOD,
ATTR_SETPOINT,
EVOHOME_DATA,
EvoService,
)
from .coordinator import EvoDataUpdateCoordinator
from .entity import EvoChild, EvoEntity
@@ -132,24 +139,6 @@ class EvoClimateEntity(EvoEntity, ClimateEntity):
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
async def async_clear_zone_override(self) -> None:
"""Clear the zone override; only supported by zones."""
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="zone_only_service",
translation_placeholders={"service": EvoService.CLEAR_ZONE_OVERRIDE},
)
async def async_set_zone_override(
self, setpoint: float, duration: timedelta | None = None
) -> None:
"""Set the zone override; only supported by zones."""
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="zone_only_service",
translation_placeholders={"service": EvoService.SET_ZONE_OVERRIDE},
)
class EvoZone(EvoChild, EvoClimateEntity):
"""Base for any evohome-compatible heating zone."""
@@ -188,22 +177,22 @@ class EvoZone(EvoChild, EvoClimateEntity):
| ClimateEntityFeature.TURN_ON
)
async def async_clear_zone_override(self) -> None:
"""Clear the zone's override, if any."""
await self.coordinator.call_client_api(self._evo_device.reset())
async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
"""Process a service request (setpoint override) for a zone."""
if service == EvoService.RESET_ZONE_OVERRIDE:
await self.coordinator.call_client_api(self._evo_device.reset())
return
async def async_set_zone_override(
self, setpoint: float, duration: timedelta | None = None
) -> None:
"""Set the zone's override (mode/setpoint)."""
temperature = max(min(setpoint, self.max_temp), self.min_temp)
# otherwise it is EvoService.SET_ZONE_OVERRIDE
temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp)
if duration is not None:
if ATTR_DURATION_UNTIL in data:
duration: timedelta = data[ATTR_DURATION_UNTIL]
if duration.total_seconds() == 0:
await self._update_schedule()
until = self.setpoints.get("next_sp_from")
else:
until = dt_util.now() + duration
until = dt_util.now() + data[ATTR_DURATION_UNTIL]
else:
until = None # indefinitely

View File

@@ -28,6 +28,7 @@ ATTR_PERIOD: Final = "period" # number of days
ATTR_DURATION: Final = "duration" # number of minutes, <24h
ATTR_SETPOINT: Final = "setpoint"
ATTR_DURATION_UNTIL: Final = "duration"
@unique
@@ -38,4 +39,4 @@ class EvoService(StrEnum):
SET_SYSTEM_MODE = "set_system_mode"
RESET_SYSTEM = "reset_system"
SET_ZONE_OVERRIDE = "set_zone_override"
CLEAR_ZONE_OVERRIDE = "clear_zone_override"
RESET_ZONE_OVERRIDE = "clear_zone_override"

View File

@@ -12,7 +12,7 @@ from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .const import DOMAIN, EvoService
from .coordinator import EvoDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -47,12 +47,22 @@ class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]):
raise NotImplementedError
if payload["unique_id"] != self._attr_unique_id:
return
if payload["service"] in (
EvoService.SET_ZONE_OVERRIDE,
EvoService.RESET_ZONE_OVERRIDE,
):
await self.async_zone_svc_request(payload["service"], payload["data"])
return
await self.async_tcs_svc_request(payload["service"], payload["data"])
async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None:
"""Process a service request (system mode) for a controller."""
raise NotImplementedError
async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
"""Process a service request (setpoint override) for a zone."""
raise NotImplementedError
@property
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return the evohome-specific state attributes."""

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any, Final
from typing import Final
from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE
from evohomeasync2.schemas.const import (
@@ -13,51 +13,40 @@ from evohomeasync2.schemas.const import (
)
import voluptuous as vol
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.const import ATTR_MODE
from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import verify_domain_control
from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, DOMAIN, EvoService
from .const import (
ATTR_DURATION,
ATTR_DURATION_UNTIL,
ATTR_PERIOD,
ATTR_SETPOINT,
DOMAIN,
EvoService,
)
from .coordinator import EvoDataUpdateCoordinator
# system mode schemas are built dynamically when the services are registered
# because supported modes can vary for edge-case systems
# Zone service schemas (registered as entity services)
CLEAR_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {}
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
vol.Required(ATTR_SETPOINT): vol.All(
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
),
vol.Optional(ATTR_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
),
}
def _register_zone_entity_services(hass: HomeAssistant) -> None:
"""Register entity-level services for zones."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
EvoService.CLEAR_ZONE_OVERRIDE,
entity_domain=CLIMATE_DOMAIN,
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
func="async_clear_zone_override",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
entity_domain=CLIMATE_DOMAIN,
schema=SET_ZONE_OVERRIDE_SCHEMA,
func="async_set_zone_override",
)
RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
{vol.Required(ATTR_ENTITY_ID): cv.entity_id}
)
SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_SETPOINT): vol.All(
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
),
vol.Optional(ATTR_DURATION_UNTIL): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
),
}
)
@callback
@@ -69,6 +58,8 @@ def setup_service_functions(
Not all Honeywell TCC-compatible systems support all operating modes. In addition,
each mode will require any of four distinct service schemas. This has to be
enumerated before registering the appropriate handlers.
It appears that all TCC-compatible systems support the same three zones modes.
"""
@verify_domain_control(DOMAIN)
@@ -88,6 +79,28 @@ def setup_service_functions(
}
async_dispatcher_send(hass, DOMAIN, payload)
@verify_domain_control(DOMAIN)
async def set_zone_override(call: ServiceCall) -> None:
"""Set the zone override (setpoint)."""
entity_id = call.data[ATTR_ENTITY_ID]
registry = er.async_get(hass)
registry_entry = registry.async_get(entity_id)
if registry_entry is None or registry_entry.platform != DOMAIN:
raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity")
if registry_entry.domain != "climate":
raise ValueError(f"'{entity_id}' is not an {DOMAIN} controller/zone")
payload = {
"unique_id": registry_entry.unique_id,
"service": call.service,
"data": call.data,
}
async_dispatcher_send(hass, DOMAIN, payload)
assert coordinator.tcs is not None # mypy
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
@@ -150,4 +163,16 @@ def setup_service_functions(
schema=vol.Schema(vol.Any(*system_mode_schemas)),
)
_register_zone_entity_services(hass)
# The zone modes are consistent across all systems and use the same schema
hass.services.async_register(
DOMAIN,
EvoService.RESET_ZONE_OVERRIDE,
set_zone_override,
schema=RESET_ZONE_OVERRIDE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
set_zone_override,
schema=SET_ZONE_OVERRIDE_SCHEMA,
)

View File

@@ -28,11 +28,14 @@ reset_system:
refresh_system:
set_zone_override:
target:
entity:
integration: evohome
domain: climate
fields:
entity_id:
required: true
example: climate.bathroom
selector:
entity:
integration: evohome
domain: climate
setpoint:
required: true
selector:
@@ -46,7 +49,10 @@ set_zone_override:
object:
clear_zone_override:
target:
entity:
integration: evohome
domain: climate
fields:
entity_id:
required: true
selector:
entity:
integration: evohome
domain: climate

View File

@@ -1,12 +1,13 @@
{
"exceptions": {
"zone_only_service": {
"message": "Only zones support the `{service}` action"
}
},
"services": {
"clear_zone_override": {
"description": "Sets a zone to follow its schedule.",
"fields": {
"entity_id": {
"description": "[%key:component::evohome::services::set_zone_override::fields::entity_id::description%]",
"name": "[%key:component::evohome::services::set_zone_override::fields::entity_id::name%]"
}
},
"name": "Clear zone override"
},
"refresh_system": {
@@ -42,6 +43,10 @@
"description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.",
"name": "Duration"
},
"entity_id": {
"description": "The entity ID of the Evohome zone.",
"name": "Entity"
},
"setpoint": {
"description": "The temperature to be used instead of the scheduled setpoint.",
"name": "Setpoint"

View File

@@ -45,10 +45,6 @@ async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
except BaseException as ex:
del stores[user_id]
future.set_exception(ex)
# Ensure the future is marked as retrieved
# since if there is no concurrent call it
# will otherwise never be retrieved.
future.exception()
raise
future.set_result(store)

View File

@@ -78,12 +78,6 @@ query ($owner: String!, $repository: String!) {
number
}
}
merged_pull_request: pullRequests(
first:1
states: MERGED
) {
total: totalCount
}
release: latestRelease {
name
url

View File

@@ -28,9 +28,6 @@
"latest_tag": {
"default": "mdi:tag"
},
"merged_pulls_count": {
"default": "mdi:source-merge"
},
"pulls_count": {
"default": "mdi:source-pull"
},

View File

@@ -75,13 +75,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["pull_request"]["total"],
),
GitHubSensorEntityDescription(
key="merged_pulls_count",
translation_key="merged_pulls_count",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data["merged_pull_request"]["total"],
),
GitHubSensorEntityDescription(
key="latest_commit",
translation_key="latest_commit",

View File

@@ -48,10 +48,6 @@
"latest_tag": {
"name": "Latest tag"
},
"merged_pulls_count": {
"name": "Merged pull requests",
"unit_of_measurement": "pull requests"
},
"pulls_count": {
"name": "Pull requests",
"unit_of_measurement": "pull requests"

View File

@@ -140,5 +140,5 @@
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["govee-ble==1.2.0"]
"requirements": ["govee-ble==0.44.0"]
}

View File

@@ -21,7 +21,6 @@ from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_NAME,
EVENT_CORE_CONFIG_UPDATE,
HASSIO_USER_NAME,
@@ -35,13 +34,11 @@ from homeassistant.core import (
async_get_hass_or_none,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
discovery_flow,
issue_registry as ir,
selector,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later
@@ -95,7 +92,6 @@ from .const import (
DATA_SUPERVISOR_INFO,
DOMAIN,
HASSIO_UPDATE_INTERVAL,
SupervisorEntityModel,
)
from .coordinator import (
HassioDataUpdateCoordinator,
@@ -151,7 +147,6 @@ SERVICE_BACKUP_FULL = "backup_full"
SERVICE_BACKUP_PARTIAL = "backup_partial"
SERVICE_RESTORE_FULL = "restore_full"
SERVICE_RESTORE_PARTIAL = "restore_partial"
SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
@@ -234,19 +229,6 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
}
)
SCHEMA_MOUNT_RELOAD = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
selector.DeviceSelectorConfig(
filter=selector.DeviceFilterSelectorConfig(
integration=DOMAIN,
model=SupervisorEntityModel.MOUNT,
)
)
)
}
)
def _is_32_bit() -> bool:
size = struct.calcsize("P")
@@ -462,42 +444,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
DOMAIN, service, async_service_handler, schema=settings.schema
)
dev_reg = dr.async_get(hass)
async def async_mount_reload(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
coordinator: HassioDataUpdateCoordinator | None = None
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_unknown_device_id",
)
if (
device.name is None
or device.model != SupervisorEntityModel.MOUNT
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
or coordinator.entry_id not in device.config_entries
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_invalid_device",
)
try:
await supervisor_client.mounts.reload_mount(device.name)
except SupervisorError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mount_reload_error",
translation_placeholders={"name": device.name, "error": str(error)},
) from error
hass.services.async_register(
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
)
async def update_info_data(_: datetime | None = None) -> None:
"""Update last available supervisor information."""
supervisor_client = get_supervisor_client(hass)

View File

@@ -46,9 +46,6 @@
"host_shutdown": {
"service": "mdi:power"
},
"mount_reload": {
"service": "mdi:reload"
},
"restore_full": {
"service": "mdi:backup-restore"
},

View File

@@ -165,13 +165,3 @@ restore_partial:
example: "password"
selector:
text:
mount_reload:
fields:
device_id:
required: true
selector:
device:
filter:
integration: hassio
model: Home Assistant Mount

View File

@@ -43,17 +43,6 @@
}
}
},
"exceptions": {
"mount_reload_error": {
"message": "Failed to reload mount {name}: {error}"
},
"mount_reload_invalid_device": {
"message": "Device is not a supervisor mount point"
},
"mount_reload_unknown_device_id": {
"message": "Device ID not found"
}
},
"issues": {
"issue_addon_boot_fail": {
"fix_flow": {
@@ -467,16 +456,6 @@
"description": "Powers off the host system.",
"name": "Power off the host system"
},
"mount_reload": {
"description": "Reloads a network storage mount.",
"fields": {
"device_id": {
"description": "The device ID of the network storage mount to reload.",
"name": "Device ID"
}
},
"name": "Reload network storage mount"
},
"restore_full": {
"description": "Restores from full backup.",
"fields": {

View File

@@ -6,12 +6,6 @@
}
},
"number": {
"audio_unmute": {
"default": "mdi:volume-high"
},
"earc_unmute": {
"default": "mdi:volume-high"
},
"oled_fade": {
"default": "mdi:cellphone-information"
},

View File

@@ -31,32 +31,6 @@ class HDFuryNumberEntityDescription(NumberEntityDescription):
NUMBERS: tuple[HDFuryNumberEntityDescription, ...] = (
HDFuryNumberEntityDescription(
key="unmutecnt",
translation_key="audio_unmute",
entity_registry_enabled_default=False,
mode=NumberMode.BOX,
native_min_value=50,
native_max_value=1000,
native_step=1,
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_audio_unmute(value),
),
HDFuryNumberEntityDescription(
key="earcunmutecnt",
translation_key="earc_unmute",
entity_registry_enabled_default=False,
mode=NumberMode.BOX,
native_min_value=0,
native_max_value=1000,
native_step=1,
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_earc_unmute(value),
),
HDFuryNumberEntityDescription(
key="oledfade",
translation_key="oled_fade",

View File

@@ -41,12 +41,6 @@
}
},
"number": {
"audio_unmute": {
"name": "Unmute delay"
},
"earc_unmute": {
"name": "eARC unmute delay"
},
"oled_fade": {
"name": "OLED fade timer"
},

View File

@@ -16,14 +16,7 @@ from homeassistant.helpers.helper_integration import (
)
from homeassistant.helpers.template import Template
from .const import (
CONF_DURATION,
CONF_END,
CONF_MIN_STATE_DURATION,
CONF_START,
PLATFORMS,
SECTION_ADVANCED_SETTINGS,
)
from .const import CONF_DURATION, CONF_END, CONF_START, PLATFORMS
from .coordinator import HistoryStatsUpdateCoordinator
from .data import HistoryStats
@@ -43,14 +36,8 @@ async def async_setup_entry(
end: str | None = entry.options.get(CONF_END)
duration: timedelta | None = None
min_state_duration: timedelta
if duration_dict := entry.options.get(CONF_DURATION):
duration = timedelta(**duration_dict)
advanced_settings = entry.options.get(SECTION_ADVANCED_SETTINGS, {})
if min_state_duration_dict := advanced_settings.get(CONF_MIN_STATE_DURATION):
min_state_duration = timedelta(**min_state_duration_dict)
else:
min_state_duration = timedelta(0)
history_stats = HistoryStats(
hass,
@@ -59,7 +46,6 @@ async def async_setup_entry(
Template(start, hass) if start else None,
Template(end, hass) if end else None,
duration,
min_state_duration,
)
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, entry, entry.title)
await coordinator.async_config_entry_first_refresh()

View File

@@ -12,7 +12,6 @@ from homeassistant.components import websocket_api
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import section
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
@@ -38,7 +37,6 @@ from homeassistant.helpers.template import Template
from .const import (
CONF_DURATION,
CONF_END,
CONF_MIN_STATE_DURATION,
CONF_PERIOD_KEYS,
CONF_START,
CONF_TYPE_KEYS,
@@ -46,7 +44,6 @@ from .const import (
CONF_TYPE_TIME,
DEFAULT_NAME,
DOMAIN,
SECTION_ADVANCED_SETTINGS,
)
from .coordinator import HistoryStatsUpdateCoordinator
from .data import HistoryStats
@@ -142,7 +139,7 @@ def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema:
vol.Optional(CONF_START): TemplateSelector(),
vol.Optional(CONF_END): TemplateSelector(),
vol.Optional(CONF_DURATION): DurationSelector(
DurationSelectorConfig(enable_day=True, allow_negative=False),
DurationSelectorConfig(enable_day=True, allow_negative=False)
),
vol.Optional(CONF_STATE_CLASS): SelectSelector(
SelectSelectorConfig(
@@ -151,18 +148,6 @@ def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema:
mode=SelectSelectorMode.DROPDOWN,
),
),
vol.Optional(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Optional(CONF_MIN_STATE_DURATION): DurationSelector(
DurationSelectorConfig(
enable_day=True, allow_negative=False
)
),
}
),
{"collapsed": True},
),
}
)
@@ -290,8 +275,6 @@ async def ws_start_preview(
start = validated_data.get(CONF_START)
end = validated_data.get(CONF_END)
duration = validated_data.get(CONF_DURATION)
advanced_settings = validated_data.get(SECTION_ADVANCED_SETTINGS, {})
min_state_duration = advanced_settings.get(CONF_MIN_STATE_DURATION)
state_class = validated_data.get(CONF_STATE_CLASS)
history_stats = HistoryStats(
@@ -301,7 +284,6 @@ async def ws_start_preview(
Template(start, hass) if start else None,
Template(end, hass) if end else None,
timedelta(**duration) if duration else None,
timedelta(**min_state_duration) if min_state_duration else timedelta(0),
True,
)
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name, True)

View File

@@ -8,7 +8,6 @@ PLATFORMS = [Platform.SENSOR]
CONF_START = "start"
CONF_END = "end"
CONF_DURATION = "duration"
CONF_MIN_STATE_DURATION = "min_state_duration"
CONF_PERIOD_KEYS = [CONF_START, CONF_END, CONF_DURATION]
CONF_TYPE_TIME = "time"
@@ -17,5 +16,3 @@ CONF_TYPE_COUNT = "count"
CONF_TYPE_KEYS = [CONF_TYPE_TIME, CONF_TYPE_RATIO, CONF_TYPE_COUNT]
DEFAULT_NAME = "unnamed statistics"
SECTION_ADVANCED_SETTINGS = "advanced_settings"

View File

@@ -47,7 +47,6 @@ class HistoryStats:
start: Template | None,
end: Template | None,
duration: datetime.timedelta | None,
min_state_duration: datetime.timedelta,
preview: bool = False,
) -> None:
"""Init the history stats manager."""
@@ -59,7 +58,6 @@ class HistoryStats:
self._has_recorder_data = False
self._entity_states = set(entity_states)
self._duration = duration
self._min_state_duration = min_state_duration.total_seconds()
self._start = start
self._end = end
self._preview = preview
@@ -245,38 +243,18 @@ class HistoryStats:
)
break
if not previous_state_matches and current_state_matches:
# We are entering a matching state.
# This marks the start of a new candidate block that may later
# qualify if it lasts at least min_state_duration.
last_state_change_timestamp = max(
start_timestamp, state_change_timestamp
)
elif previous_state_matches and not current_state_matches:
# We are leaving a matching state.
# This closes the current matching block and allows to
# evaluate its total duration.
block_duration = state_change_timestamp - last_state_change_timestamp
if block_duration >= self._min_state_duration:
# The block lasted long enough so we increment match count
# and accumulate its duration.
elapsed += block_duration
match_count += 1
if previous_state_matches:
elapsed += state_change_timestamp - last_state_change_timestamp
elif current_state_matches:
match_count += 1
previous_state_matches = current_state_matches
last_state_change_timestamp = max(start_timestamp, state_change_timestamp)
# Count time elapsed between last history state and end of measure
if previous_state_matches:
# We are still inside a matching block at the end of the
# measurement window. This block has not been closed by a
# transition, so we evaluate it up to measure_end.
measure_end = min(end_timestamp, now_timestamp)
last_state_duration = max(0, measure_end - last_state_change_timestamp)
if last_state_duration >= self._min_state_duration:
# The open block lasted long enough so we increment match count
# and accumulate its duration.
elapsed += last_state_duration
match_count += 1
elapsed += measure_end - last_state_change_timestamp
# Save value in seconds
seconds_matched = elapsed

View File

@@ -42,7 +42,6 @@ from . import HistoryStatsConfigEntry
from .const import (
CONF_DURATION,
CONF_END,
CONF_MIN_STATE_DURATION,
CONF_PERIOD_KEYS,
CONF_START,
CONF_TYPE_COUNT,
@@ -64,8 +63,6 @@ UNITS: dict[str, str] = {
}
ICON = "mdi:chart-line"
DEFAULT_MIN_STATE_DURATION = datetime.timedelta(0)
def exactly_two_period_keys[_T: dict[str, Any]](conf: _T) -> _T:
"""Ensure exactly 2 of CONF_PERIOD_KEYS are provided."""
@@ -94,9 +91,6 @@ PLATFORM_SCHEMA = vol.All(
vol.Optional(CONF_START): cv.template,
vol.Optional(CONF_END): cv.template,
vol.Optional(CONF_DURATION): cv.time_period,
vol.Optional(
CONF_MIN_STATE_DURATION, default=DEFAULT_MIN_STATE_DURATION
): cv.time_period,
vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string,
@@ -126,7 +120,6 @@ async def async_setup_platform(
start: Template | None = config.get(CONF_START)
end: Template | None = config.get(CONF_END)
duration: datetime.timedelta | None = config.get(CONF_DURATION)
min_state_duration: datetime.timedelta = config[CONF_MIN_STATE_DURATION]
sensor_type: str = config[CONF_TYPE]
name: str = config[CONF_NAME]
unique_id: str | None = config.get(CONF_UNIQUE_ID)
@@ -134,9 +127,7 @@ async def async_setup_platform(
CONF_STATE_CLASS, SensorStateClass.MEASUREMENT
)
history_stats = HistoryStats(
hass, entity_id, entity_states, start, end, duration, min_state_duration
)
history_stats = HistoryStats(hass, entity_id, entity_states, start, end, duration)
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name)
await coordinator.async_refresh()
if not coordinator.last_update_success:

View File

@@ -19,23 +19,14 @@
},
"data_description": {
"duration": "Duration of the measure.",
"end": "When to stop the measure (timestamp or datetime). Can be a template.",
"end": "When to stop the measure (timestamp or datetime). Can be a template",
"entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]",
"start": "When to start the measure (timestamp or datetime). Can be a template.",
"state": "[%key:component::history_stats::config::step::user::data_description::state%]",
"state_class": "The state class for statistics calculation.",
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
},
"description": "Read the documentation for further details on how to configure the history stats sensor using these options.",
"sections": {
"advanced_settings": {
"data": { "min_state_duration": "Minimum state duration" },
"data_description": {
"min_state_duration": "The minimum state duration to account for the statistics. Default is 0 seconds."
},
"name": "Advanced settings"
}
}
"description": "Read the documentation for further details on how to configure the history stats sensor using these options."
},
"state": {
"data": {
@@ -91,18 +82,7 @@
"state_class": "The state class for statistics calculation. Changing the state class will require statistics to be reset.",
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
},
"description": "[%key:component::history_stats::config::step::options::description%]",
"sections": {
"advanced_settings": {
"data": {
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::advanced_settings::data::min_state_duration%]"
},
"data_description": {
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::advanced_settings::data_description::min_state_duration%]"
},
"name": "[%key:component::history_stats::config::step::options::sections::advanced_settings::name%]"
}
}
"description": "[%key:component::history_stats::config::step::options::description%]"
}
}
},

View File

@@ -10,5 +10,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["apyhiveapi"],
"requirements": ["pyhive-integration==1.0.8"]
"requirements": ["pyhive-integration==1.0.7"]
}

View File

@@ -57,8 +57,8 @@
"battery_charge_discharge_state": {
"name": "Battery charge/discharge state",
"state": {
"charging": "[%key:common::state::charging%]",
"discharging": "[%key:common::state::discharging%]",
"charging": "Charging",
"discharging": "Discharging",
"static": "Static"
}
},

View File

@@ -1,153 +0,0 @@
"""Provides functionality to interact with infrared devices."""
from __future__ import annotations
from abc import abstractmethod
from datetime import timedelta
import logging
from typing import final
from infrared_protocols import Command as InfraredCommand
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
__all__ = [
"DOMAIN",
"InfraredEntity",
"InfraredEntityDescription",
"async_get_emitters",
"async_send_command",
]
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the infrared domain."""
component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
await component.async_setup(config)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
@callback
def async_get_emitters(hass: HomeAssistant) -> list[InfraredEntity]:
"""Get all infrared emitters."""
component = hass.data.get(DATA_COMPONENT)
if component is None:
return []
return list(component.entities)
async def async_send_command(
hass: HomeAssistant,
entity_id_or_uuid: str,
command: InfraredCommand,
context: Context | None = None,
) -> None:
"""Send an IR command to the specified infrared entity.
Raises:
HomeAssistantError: If the infrared entity is not found.
"""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="component_not_loaded",
)
ent_reg = er.async_get(hass)
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
entity = component.get_entity(entity_id)
if entity is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
translation_placeholders={"entity_id": entity_id},
)
if context is not None:
entity.async_set_context(context)
await entity.async_send_command_internal(command)
class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared entities."""
class InfraredEntity(RestoreEntity):
"""Base class for infrared transmitter entities."""
entity_description: InfraredEntityDescription
_attr_should_poll = False
_attr_state: None = None
__last_command_sent: str | None = None
@property
@final
def state(self) -> str | None:
"""Return the entity state."""
return self.__last_command_sent
@final
async def async_send_command_internal(self, command: InfraredCommand) -> None:
"""Send an IR command and update state.
Should not be overridden, handles setting last sent timestamp.
"""
await self.async_send_command(command)
self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds")
self.async_write_ha_state()
@final
async def async_internal_added_to_hass(self) -> None:
"""Call when the infrared entity is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
self.__last_command_sent = state.state
@abstractmethod
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command.
Args:
command: The IR command to send.
Raises:
HomeAssistantError: If transmission fails.
"""

View File

@@ -1,5 +0,0 @@
"""Constants for the Infrared integration."""
from typing import Final
DOMAIN: Final = "infrared"

View File

@@ -1,7 +0,0 @@
{
"entity_component": {
"_": {
"default": "mdi:led-on"
}
}
}

View File

@@ -1,9 +0,0 @@
{
"domain": "infrared",
"name": "Infrared",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==1.0.0"]
}

View File

@@ -1,10 +0,0 @@
{
"exceptions": {
"component_not_loaded": {
"message": "Infrared component not loaded"
},
"entity_not_found": {
"message": "Infrared entity `{entity_id}` not found"
}
}
}

View File

@@ -56,9 +56,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON,
Platform.FAN,
Platform.IMAGE,
Platform.INFRARED,
Platform.LAWN_MOWER,
Platform.LOCK,
Platform.NOTIFY,
@@ -133,9 +131,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Notify backup listeners
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
# Reload config entry when subentries are added/removed/updated
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
# Subscribe to labs feature updates for kitchen_sink preview repair
entry.async_on_unload(
async_subscribe_preview_feature(
@@ -152,11 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry on update (e.g. subentry added/removed)."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload config entry."""
# Notify backup listeners

View File

@@ -8,23 +8,18 @@ from typing import Any
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
)
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
OptionsFlow,
OptionsFlowWithReload,
SubentryFlowResult,
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
from . import DOMAIN
CONF_BOOLEAN = "bool"
CONF_INT = "int"
@@ -49,10 +44,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {
"entity": SubentryFlowHandler,
"infrared_fan": InfraredFanSubentryFlowHandler,
}
return {"entity": SubentryFlowHandler}
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Set the config entry up from yaml."""
@@ -73,7 +65,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="reauth_successful")
class OptionsFlowHandler(OptionsFlow):
class OptionsFlowHandler(OptionsFlowWithReload):
"""Handle options."""
async def async_step_init(
@@ -154,7 +146,7 @@ class SubentryFlowHandler(ConfigSubentryFlow):
"""Reconfigure a sensor."""
if user_input is not None:
title = user_input.pop("name")
return self.async_update_and_abort(
return self.async_update_reload_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
@@ -170,35 +162,3 @@ class SubentryFlowHandler(ConfigSubentryFlow):
}
),
)
class InfraredFanSubentryFlowHandler(ConfigSubentryFlow):
"""Handle infrared fan subentry flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to add an infrared fan."""
entities = async_get_emitters(self.hass)
if not entities:
return self.async_abort(reason="no_emitters")
if user_input is not None:
title = user_input.pop("name")
return self.async_create_entry(data=user_input, title=title)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("name"): str,
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=[entity.entity_id for entity in entities],
)
),
}
),
)

View File

@@ -7,7 +7,6 @@ from collections.abc import Callable
from homeassistant.util.hass_dict import HassKey
DOMAIN = "kitchen_sink"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
)

View File

@@ -1,150 +0,0 @@
"""Demo platform that offers a fake infrared fan entity."""
from __future__ import annotations
from typing import Any
import infrared_protocols
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.components.infrared import async_send_command
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
PARALLEL_UPDATES = 0
DUMMY_FAN_ADDRESS = 0x1234
DUMMY_CMD_POWER_ON = 0x01
DUMMY_CMD_POWER_OFF = 0x02
DUMMY_CMD_SPEED_LOW = 0x03
DUMMY_CMD_SPEED_MEDIUM = 0x04
DUMMY_CMD_SPEED_HIGH = 0x05
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo infrared fan platform."""
for subentry_id, subentry in config_entry.subentries.items():
if subentry.subentry_type != "infrared_fan":
continue
async_add_entities(
[
DemoInfraredFan(
subentry_id=subentry_id,
device_name=subentry.title,
infrared_entity_id=subentry.data[CONF_INFRARED_ENTITY_ID],
)
],
config_subentry_id=subentry_id,
)
class DemoInfraredFan(FanEntity):
"""Representation of a demo infrared fan entity."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
_attr_assumed_state = True
_attr_speed_count = 3
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
def __init__(
self,
subentry_id: str,
device_name: str,
infrared_entity_id: str,
) -> None:
"""Initialize the demo infrared fan entity."""
self._infrared_entity_id = infrared_entity_id
self._attr_unique_id = subentry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry_id)},
name=device_name,
)
self._attr_percentage = 0
async def async_added_to_hass(self) -> None:
"""Subscribe to infrared entity state changes."""
await super().async_added_to_hass()
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
new_state = event.data["new_state"]
self._attr_available = (
new_state is not None and new_state.state != STATE_UNAVAILABLE
)
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._infrared_entity_id], _async_ir_state_changed
)
)
# Set initial availability based on current infrared entity state
ir_state = self.hass.states.get(self._infrared_entity_id)
self._attr_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
async def _send_command(self, command_code: int) -> None:
"""Send an IR command using the NEC protocol."""
command = infrared_protocols.NECCommand(
address=DUMMY_FAN_ADDRESS,
command=command_code,
modulation=38000,
)
await async_send_command(
self.hass, self._infrared_entity_id, command, context=self._context
)
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
if percentage is not None:
await self.async_set_percentage(percentage)
return
await self._send_command(DUMMY_CMD_POWER_ON)
self._attr_percentage = 33
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
await self._send_command(DUMMY_CMD_POWER_OFF)
self._attr_percentage = 0
self.async_write_ha_state()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
if percentage == 0:
await self.async_turn_off()
return
if percentage <= 33:
await self._send_command(DUMMY_CMD_SPEED_LOW)
elif percentage <= 66:
await self._send_command(DUMMY_CMD_SPEED_MEDIUM)
else:
await self._send_command(DUMMY_CMD_SPEED_HIGH)
self._attr_percentage = percentage
self.async_write_ha_state()

View File

@@ -1,65 +0,0 @@
"""Demo platform that offers a fake infrared entity."""
from __future__ import annotations
import infrared_protocols
from homeassistant.components import persistent_notification
from homeassistant.components.infrared import InfraredEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo infrared platform."""
async_add_entities(
[
DemoInfrared(
unique_id="ir_transmitter",
device_name="IR Blaster",
entity_name="Infrared Transmitter",
),
]
)
class DemoInfrared(InfraredEntity):
"""Representation of a demo infrared entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
unique_id: str,
device_name: str,
entity_name: str,
) -> None:
"""Initialize the demo infrared entity."""
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
self._attr_name = entity_name
async def async_send_command(self, command: infrared_protocols.Command) -> None:
"""Send an IR command."""
timings = [
interval
for timing in command.get_raw_timings()
for interval in (timing.high_us, -timing.low_us)
]
persistent_notification.async_create(
self.hass, str(timings), title="Infrared Command"
)

View File

@@ -101,8 +101,6 @@ async def async_setup_entry(
)
for subentry_id, subentry in config_entry.subentries.items():
if subentry.subentry_type != "entity":
continue
async_add_entities(
[
DemoSensor(

View File

@@ -32,24 +32,6 @@
"description": "Reconfigure the sensor"
}
}
},
"infrared_fan": {
"abort": {
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"entry_type": "Infrared fan",
"initiate_flow": {
"user": "Add infrared fan"
},
"step": {
"user": {
"data": {
"infrared_entity_id": "Infrared transmitter",
"name": "[%key:common::config_flow::data::name%]"
},
"description": "Select an infrared transmitter to control the fan."
}
}
}
},
"device": {

View File

@@ -2,8 +2,6 @@
import logging
from chip.clusters import Objects as clusters
ADDON_SLUG = "core_matter_server"
CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon"
@@ -17,100 +15,3 @@ ID_TYPE_DEVICE_ID = "deviceid"
ID_TYPE_SERIAL = "serial"
FEATUREMAP_ATTRIBUTE_ID = 65532
# --- Lock domain constants ---
# Shared field keys
ATTR_CREDENTIAL_RULE = "credential_rule"
ATTR_MAX_CREDENTIALS_PER_USER = "max_credentials_per_user"
ATTR_MAX_PIN_USERS = "max_pin_users"
ATTR_MAX_RFID_USERS = "max_rfid_users"
ATTR_MAX_USERS = "max_users"
ATTR_SUPPORTS_USER_MGMT = "supports_user_management"
ATTR_USER_INDEX = "user_index"
ATTR_USER_NAME = "user_name"
ATTR_USER_STATUS = "user_status"
ATTR_USER_TYPE = "user_type"
# Magic values
CLEAR_ALL_INDEX = 0xFFFE # Matter spec: pass to ClearUser/ClearCredential to clear all
# Timed request timeout for lock commands that modify state.
# 10 seconds accounts for Thread network latency and retransmissions.
LOCK_TIMED_REQUEST_TIMEOUT_MS = 10000
# Credential field keys
ATTR_CREDENTIAL_DATA = "credential_data"
ATTR_CREDENTIAL_INDEX = "credential_index"
ATTR_CREDENTIAL_TYPE = "credential_type"
# Credential type strings
CRED_TYPE_FACE = "face"
CRED_TYPE_FINGERPRINT = "fingerprint"
CRED_TYPE_FINGER_VEIN = "finger_vein"
CRED_TYPE_PIN = "pin"
CRED_TYPE_RFID = "rfid"
# User status mapping (Matter DoorLock UserStatusEnum)
_UserStatus = clusters.DoorLock.Enums.UserStatusEnum
USER_STATUS_MAP: dict[int, str] = {
_UserStatus.kAvailable: "available",
_UserStatus.kOccupiedEnabled: "occupied_enabled",
_UserStatus.kOccupiedDisabled: "occupied_disabled",
}
USER_STATUS_REVERSE_MAP: dict[str, int] = {v: k for k, v in USER_STATUS_MAP.items()}
# User type mapping (Matter DoorLock UserTypeEnum)
_UserType = clusters.DoorLock.Enums.UserTypeEnum
USER_TYPE_MAP: dict[int, str] = {
_UserType.kUnrestrictedUser: "unrestricted_user",
_UserType.kYearDayScheduleUser: "year_day_schedule_user",
_UserType.kWeekDayScheduleUser: "week_day_schedule_user",
_UserType.kProgrammingUser: "programming_user",
_UserType.kNonAccessUser: "non_access_user",
_UserType.kForcedUser: "forced_user",
_UserType.kDisposableUser: "disposable_user",
_UserType.kExpiringUser: "expiring_user",
_UserType.kScheduleRestrictedUser: "schedule_restricted_user",
_UserType.kRemoteOnlyUser: "remote_only_user",
}
USER_TYPE_REVERSE_MAP: dict[str, int] = {v: k for k, v in USER_TYPE_MAP.items()}
# Credential type mapping (Matter DoorLock CredentialTypeEnum)
_CredentialType = clusters.DoorLock.Enums.CredentialTypeEnum
CREDENTIAL_TYPE_MAP: dict[int, str] = {
_CredentialType.kProgrammingPIN: "programming_pin",
_CredentialType.kPin: CRED_TYPE_PIN,
_CredentialType.kRfid: CRED_TYPE_RFID,
_CredentialType.kFingerprint: CRED_TYPE_FINGERPRINT,
_CredentialType.kFingerVein: CRED_TYPE_FINGER_VEIN,
_CredentialType.kFace: CRED_TYPE_FACE,
_CredentialType.kAliroCredentialIssuerKey: "aliro_credential_issuer_key",
_CredentialType.kAliroEvictableEndpointKey: "aliro_evictable_endpoint_key",
_CredentialType.kAliroNonEvictableEndpointKey: "aliro_non_evictable_endpoint_key",
}
# Credential rule mapping (Matter DoorLock CredentialRuleEnum)
_CredentialRule = clusters.DoorLock.Enums.CredentialRuleEnum
CREDENTIAL_RULE_MAP: dict[int, str] = {
_CredentialRule.kSingle: "single",
_CredentialRule.kDual: "dual",
_CredentialRule.kTri: "tri",
}
CREDENTIAL_RULE_REVERSE_MAP: dict[str, int] = {
v: k for k, v in CREDENTIAL_RULE_MAP.items()
}
# Reverse mapping for credential types (str -> int)
CREDENTIAL_TYPE_REVERSE_MAP: dict[str, int] = {
v: k for k, v in CREDENTIAL_TYPE_MAP.items()
}
# Credential types allowed in set/clear services (excludes programming_pin, aliro_*)
SERVICE_CREDENTIAL_TYPES = [
CRED_TYPE_PIN,
CRED_TYPE_RFID,
CRED_TYPE_FINGERPRINT,
CRED_TYPE_FINGER_VEIN,
CRED_TYPE_FACE,
]

View File

@@ -174,27 +174,6 @@
}
},
"services": {
"clear_lock_credential": {
"service": "mdi:key-remove"
},
"clear_lock_user": {
"service": "mdi:account-remove"
},
"get_lock_credential_status": {
"service": "mdi:key-chain"
},
"get_lock_info": {
"service": "mdi:lock-question"
},
"get_lock_users": {
"service": "mdi:account-multiple"
},
"set_lock_credential": {
"service": "mdi:key-plus"
},
"set_lock_user": {
"service": "mdi:account-lock"
},
"water_heater_boost": {
"service": "mdi:water-boiler"
}

View File

@@ -7,7 +7,6 @@ from dataclasses import dataclass
from typing import Any
from chip.clusters import Objects as clusters
from matter_server.common.errors import MatterError
from matter_server.common.models import EventType, MatterNodeEvent
from homeassistant.components.lock import (
@@ -18,56 +17,32 @@ from homeassistant.components.lock import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CODE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_CREDENTIAL_DATA,
ATTR_CREDENTIAL_INDEX,
ATTR_CREDENTIAL_RULE,
ATTR_CREDENTIAL_TYPE,
ATTR_USER_INDEX,
ATTR_USER_NAME,
ATTR_USER_STATUS,
ATTR_USER_TYPE,
LOCK_TIMED_REQUEST_TIMEOUT_MS,
LOGGER,
)
from .const import LOGGER
from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter
from .lock_helpers import (
DoorLockFeature,
GetLockCredentialStatusResult,
GetLockInfoResult,
GetLockUsersResult,
SetLockCredentialResult,
clear_lock_credential,
clear_lock_user,
get_lock_credential_status,
get_lock_info,
get_lock_users,
set_lock_credential,
set_lock_user,
)
from .models import MatterDiscoverySchema
# Door lock operation source mapping (Matter DoorLock OperationSourceEnum)
_OperationSource = clusters.DoorLock.Enums.OperationSourceEnum
DOOR_LOCK_OPERATION_SOURCE: dict[int, str] = {
_OperationSource.kUnspecified: "Unspecified",
_OperationSource.kManual: "Manual",
_OperationSource.kProprietaryRemote: "Proprietary Remote",
_OperationSource.kKeypad: "Keypad",
_OperationSource.kAuto: "Auto",
_OperationSource.kButton: "Button",
_OperationSource.kSchedule: "Schedule",
_OperationSource.kRemote: "Remote",
_OperationSource.kRfid: "RFID",
_OperationSource.kBiometric: "Biometric",
_OperationSource.kAliro: "Aliro",
DOOR_LOCK_OPERATION_SOURCE = {
# mapping from operation source id's to textual representation
0: "Unspecified",
1: "Manual", # [Optional]
2: "Proprietary Remote", # [Optional]
3: "Keypad", # [Optional]
4: "Auto", # [Optional]
5: "Button", # [Optional]
6: "Schedule", # [HDSCH]
7: "Remote", # [M]
8: "RFID", # [RID]
9: "Biometric", # [USR]
10: "Aliro", # [Aliro]
}
DoorLockFeature = clusters.DoorLock.Bitmaps.Feature
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -123,15 +98,17 @@ class MatterLock(MatterEntity, LockEntity):
node_event.data,
)
# Handle the DoorLock events
# handle the DoorLock events
node_event_data: dict[str, int] = node_event.data or {}
match node_event.event_id:
case clusters.DoorLock.Events.LockOperation.event_id:
case (
clusters.DoorLock.Events.LockOperation.event_id
): # Lock cluster event 2
# update the changed_by attribute to indicate lock operation source
operation_source: int = node_event_data.get("operationSource", -1)
source_name = DOOR_LOCK_OPERATION_SOURCE.get(
self._attr_changed_by = DOOR_LOCK_OPERATION_SOURCE.get(
operation_source, "Unknown"
)
self._attr_changed_by = source_name
self.async_write_ha_state()
@property
@@ -169,7 +146,7 @@ class MatterLock(MatterEntity, LockEntity):
code_bytes = code.encode() if code else None
await self.send_device_command(
command=clusters.DoorLock.Commands.LockDoor(code_bytes),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
timed_request_timeout_ms=1000,
)
async def async_unlock(self, **kwargs: Any) -> None:
@@ -191,12 +168,12 @@ class MatterLock(MatterEntity, LockEntity):
# and unlatch on the HA 'open' command.
await self.send_device_command(
command=clusters.DoorLock.Commands.UnboltDoor(code_bytes),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
timed_request_timeout_ms=1000,
)
else:
await self.send_device_command(
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
timed_request_timeout_ms=1000,
)
async def async_open(self, **kwargs: Any) -> None:
@@ -213,7 +190,7 @@ class MatterLock(MatterEntity, LockEntity):
code_bytes = code.encode() if code else None
await self.send_device_command(
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
timed_request_timeout_ms=1000,
)
@callback
@@ -279,109 +256,6 @@ class MatterLock(MatterEntity, LockEntity):
supported_features |= LockEntityFeature.OPEN
self._attr_supported_features = supported_features
# --- Entity service methods ---
async def async_set_lock_user(self, **kwargs: Any) -> None:
"""Set a lock user (full CRUD)."""
try:
await set_lock_user(
self.matter_client,
self._endpoint.node,
user_index=kwargs.get(ATTR_USER_INDEX),
user_name=kwargs.get(ATTR_USER_NAME),
user_type=kwargs.get(ATTR_USER_TYPE),
credential_rule=kwargs.get(ATTR_CREDENTIAL_RULE),
)
except MatterError as err:
raise HomeAssistantError(
f"Failed to set lock user on {self.entity_id}: {err}"
) from err
async def async_clear_lock_user(self, **kwargs: Any) -> None:
"""Clear a lock user."""
try:
await clear_lock_user(
self.matter_client,
self._endpoint.node,
kwargs[ATTR_USER_INDEX],
)
except MatterError as err:
raise HomeAssistantError(
f"Failed to clear lock user on {self.entity_id}: {err}"
) from err
async def async_get_lock_info(self) -> GetLockInfoResult:
"""Get lock capabilities and configuration info."""
try:
return await get_lock_info(
self.matter_client,
self._endpoint.node,
)
except MatterError as err:
raise HomeAssistantError(
f"Failed to get lock info for {self.entity_id}: {err}"
) from err
async def async_get_lock_users(self) -> GetLockUsersResult:
"""Get all users from the lock."""
try:
return await get_lock_users(
self.matter_client,
self._endpoint.node,
)
except MatterError as err:
raise HomeAssistantError(
f"Failed to get lock users for {self.entity_id}: {err}"
) from err
async def async_set_lock_credential(self, **kwargs: Any) -> SetLockCredentialResult:
"""Set a credential on the lock."""
try:
return await set_lock_credential(
self.matter_client,
self._endpoint.node,
credential_type=kwargs[ATTR_CREDENTIAL_TYPE],
credential_data=kwargs[ATTR_CREDENTIAL_DATA],
credential_index=kwargs.get(ATTR_CREDENTIAL_INDEX),
user_index=kwargs.get(ATTR_USER_INDEX),
user_status=kwargs.get(ATTR_USER_STATUS),
user_type=kwargs.get(ATTR_USER_TYPE),
)
except MatterError as err:
raise HomeAssistantError(
f"Failed to set lock credential on {self.entity_id}: {err}"
) from err
async def async_clear_lock_credential(self, **kwargs: Any) -> None:
"""Clear a credential from the lock."""
try:
await clear_lock_credential(
self.matter_client,
self._endpoint.node,
credential_type=kwargs[ATTR_CREDENTIAL_TYPE],
credential_index=kwargs[ATTR_CREDENTIAL_INDEX],
)
except MatterError as err:
raise HomeAssistantError(
f"Failed to clear lock credential on {self.entity_id}: {err}"
) from err
async def async_get_lock_credential_status(
self, **kwargs: Any
) -> GetLockCredentialStatusResult:
"""Get the status of a credential slot on the lock."""
try:
return await get_lock_credential_status(
self.matter_client,
self._endpoint.node,
credential_type=kwargs[ATTR_CREDENTIAL_TYPE],
credential_index=kwargs[ATTR_CREDENTIAL_INDEX],
)
except MatterError as err:
raise HomeAssistantError(
f"Failed to get credential status for {self.entity_id}: {err}"
) from err
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(

View File

@@ -1,881 +0,0 @@
"""Lock-specific helpers for the Matter integration.
Provides DoorLock cluster endpoint resolution, feature detection, and
business logic for lock user/credential management.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, TypedDict
from chip.clusters import Objects as clusters
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from .const import (
CLEAR_ALL_INDEX,
CRED_TYPE_FACE,
CRED_TYPE_FINGER_VEIN,
CRED_TYPE_FINGERPRINT,
CRED_TYPE_PIN,
CRED_TYPE_RFID,
CREDENTIAL_RULE_MAP,
CREDENTIAL_RULE_REVERSE_MAP,
CREDENTIAL_TYPE_MAP,
CREDENTIAL_TYPE_REVERSE_MAP,
LOCK_TIMED_REQUEST_TIMEOUT_MS,
USER_STATUS_MAP,
USER_STATUS_REVERSE_MAP,
USER_TYPE_MAP,
USER_TYPE_REVERSE_MAP,
)
# Error translation keys (used in ServiceValidationError/HomeAssistantError)
ERR_CREDENTIAL_TYPE_NOT_SUPPORTED = "credential_type_not_supported"
ERR_INVALID_CREDENTIAL_DATA = "invalid_credential_data"
# SetCredential response status mapping (Matter DlStatus)
_DlStatus = clusters.DoorLock.Enums.DlStatus
SET_CREDENTIAL_STATUS_MAP: dict[int, str] = {
_DlStatus.kSuccess: "success",
_DlStatus.kFailure: "failure",
_DlStatus.kDuplicate: "duplicate",
_DlStatus.kOccupied: "occupied",
}
if TYPE_CHECKING:
from matter_server.client import MatterClient
from matter_server.client.models.node import MatterEndpoint, MatterNode
# DoorLock Feature bitmap from Matter SDK
DoorLockFeature = clusters.DoorLock.Bitmaps.Feature
# --- TypedDicts for service action responses ---
class LockUserCredentialData(TypedDict):
"""Credential data within a user response."""
type: str
index: int | None
class LockUserData(TypedDict):
"""User data returned from lock queries."""
user_index: int | None
user_name: str | None
user_unique_id: int | None
user_status: str
user_type: str
credential_rule: str
credentials: list[LockUserCredentialData]
next_user_index: int | None
class SetLockUserResult(TypedDict):
"""Result of set_lock_user service action."""
user_index: int
class GetLockUsersResult(TypedDict):
"""Result of get_lock_users service action."""
max_users: int
users: list[LockUserData]
class GetLockInfoResult(TypedDict):
"""Result of get_lock_info service action."""
supports_user_management: bool
supported_credential_types: list[str]
max_users: int | None
max_pin_users: int | None
max_rfid_users: int | None
max_credentials_per_user: int | None
min_pin_length: int | None
max_pin_length: int | None
min_rfid_length: int | None
max_rfid_length: int | None
class SetLockCredentialResult(TypedDict):
"""Result of set_lock_credential service action."""
credential_index: int
user_index: int | None
next_credential_index: int | None
class GetLockCredentialStatusResult(TypedDict):
"""Result of get_lock_credential_status service action."""
credential_exists: bool
user_index: int | None
next_credential_index: int | None
def _get_lock_endpoint_from_node(node: MatterNode) -> MatterEndpoint | None:
"""Get the DoorLock endpoint from a node.
Returns the first endpoint that has the DoorLock cluster, or None if not found.
"""
for endpoint in node.endpoints.values():
if endpoint.has_cluster(clusters.DoorLock):
return endpoint
return None
def _get_feature_map(endpoint: MatterEndpoint) -> int | None:
"""Read the DoorLock FeatureMap attribute from an endpoint."""
value: int | None = endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.FeatureMap
)
return value
def _lock_supports_usr_feature(endpoint: MatterEndpoint) -> bool:
"""Check if lock endpoint supports USR (User) feature.
The USR feature indicates the lock supports user and credential management
commands like SetUser, GetUser, SetCredential, etc.
"""
feature_map = _get_feature_map(endpoint)
if feature_map is None:
return False
return bool(feature_map & DoorLockFeature.kUser)
# --- Pure utility functions ---
def _get_attr(obj: Any, attr: str) -> Any:
"""Get attribute from object or dict.
Matter SDK responses can be either dataclass objects or dicts depending on
the SDK version and serialization context.
"""
if isinstance(obj, dict):
return obj.get(attr)
return getattr(obj, attr, None)
def _get_supported_credential_types(feature_map: int) -> list[str]:
"""Get list of supported credential types from feature map."""
types = []
if feature_map & DoorLockFeature.kPinCredential:
types.append(CRED_TYPE_PIN)
if feature_map & DoorLockFeature.kRfidCredential:
types.append(CRED_TYPE_RFID)
if feature_map & DoorLockFeature.kFingerCredentials:
types.append(CRED_TYPE_FINGERPRINT)
if feature_map & DoorLockFeature.kFaceCredentials:
types.append(CRED_TYPE_FACE)
return types
def _format_user_response(user_data: Any) -> LockUserData | None:
"""Format GetUser response to API response format.
Returns None if the user slot is empty (no userStatus).
"""
if user_data is None:
return None
user_status = _get_attr(user_data, "userStatus")
if user_status is None:
return None
creds = _get_attr(user_data, "credentials")
credentials: list[LockUserCredentialData] = [
LockUserCredentialData(
type=CREDENTIAL_TYPE_MAP.get(_get_attr(cred, "credentialType"), "unknown"),
index=_get_attr(cred, "credentialIndex"),
)
for cred in (creds or [])
]
return LockUserData(
user_index=_get_attr(user_data, "userIndex"),
user_name=_get_attr(user_data, "userName"),
user_unique_id=_get_attr(user_data, "userUniqueID"),
user_status=USER_STATUS_MAP.get(user_status, "unknown"),
user_type=USER_TYPE_MAP.get(_get_attr(user_data, "userType"), "unknown"),
credential_rule=CREDENTIAL_RULE_MAP.get(
_get_attr(user_data, "credentialRule"), "unknown"
),
credentials=credentials,
next_user_index=_get_attr(user_data, "nextUserIndex"),
)
# --- Credential management helpers ---
async def _clear_user_credentials(
matter_client: MatterClient,
node_id: int,
endpoint_id: int,
user_index: int,
) -> None:
"""Clear all credentials for a specific user.
Fetches the user to get credential list, then clears each credential.
"""
get_user_response = await matter_client.send_device_command(
node_id=node_id,
endpoint_id=endpoint_id,
command=clusters.DoorLock.Commands.GetUser(userIndex=user_index),
)
creds = _get_attr(get_user_response, "credentials")
if not creds:
return
for cred in creds:
cred_type = _get_attr(cred, "credentialType")
cred_index = _get_attr(cred, "credentialIndex")
await matter_client.send_device_command(
node_id=node_id,
endpoint_id=endpoint_id,
command=clusters.DoorLock.Commands.ClearCredential(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=cred_type,
credentialIndex=cred_index,
),
),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
)
class LockEndpointNotFoundError(HomeAssistantError):
"""Lock endpoint not found on node."""
class UsrFeatureNotSupportedError(ServiceValidationError):
"""Lock does not support USR (user management) feature."""
class UserSlotEmptyError(ServiceValidationError):
"""User slot is empty."""
class NoAvailableUserSlotsError(ServiceValidationError):
"""No available user slots on the lock."""
class CredentialTypeNotSupportedError(ServiceValidationError):
"""Lock does not support the requested credential type."""
class CredentialDataInvalidError(ServiceValidationError):
"""Credential data fails validation."""
class SetCredentialFailedError(HomeAssistantError):
"""SetCredential command returned a non-success status."""
def _get_lock_endpoint_or_raise(node: MatterNode) -> MatterEndpoint:
"""Get the DoorLock endpoint from a node or raise an error."""
lock_endpoint = _get_lock_endpoint_from_node(node)
if lock_endpoint is None:
raise LockEndpointNotFoundError("No lock endpoint found on this device")
return lock_endpoint
def _ensure_usr_support(lock_endpoint: MatterEndpoint) -> None:
"""Ensure the lock endpoint supports USR (user management) feature.
Raises UsrFeatureNotSupportedError if the lock doesn't support user management.
"""
if not _lock_supports_usr_feature(lock_endpoint):
raise UsrFeatureNotSupportedError(
"Lock does not support user/credential management"
)
# --- High-level business logic functions ---
async def get_lock_info(
matter_client: MatterClient,
node: MatterNode,
) -> GetLockInfoResult:
"""Get lock capabilities and configuration info.
Returns a typed dict with lock capability information.
Raises HomeAssistantError if lock endpoint not found.
"""
lock_endpoint = _get_lock_endpoint_or_raise(node)
supports_usr = _lock_supports_usr_feature(lock_endpoint)
# Get feature map for credential type detection
feature_map = (
lock_endpoint.get_attribute_value(None, clusters.DoorLock.Attributes.FeatureMap)
or 0
)
result = GetLockInfoResult(
supports_user_management=supports_usr,
supported_credential_types=_get_supported_credential_types(feature_map),
max_users=None,
max_pin_users=None,
max_rfid_users=None,
max_credentials_per_user=None,
min_pin_length=None,
max_pin_length=None,
min_rfid_length=None,
max_rfid_length=None,
)
# Populate capacity info if USR feature is supported
if supports_usr:
result["max_users"] = lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.NumberOfTotalUsersSupported
)
result["max_pin_users"] = lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.NumberOfPINUsersSupported
)
result["max_rfid_users"] = lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.NumberOfRFIDUsersSupported
)
result["max_credentials_per_user"] = lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.NumberOfCredentialsSupportedPerUser
)
result["min_pin_length"] = lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.MinPINCodeLength
)
result["max_pin_length"] = lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.MaxPINCodeLength
)
result["min_rfid_length"] = lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.MinRFIDCodeLength
)
result["max_rfid_length"] = lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.MaxRFIDCodeLength
)
return result
async def set_lock_user(
matter_client: MatterClient,
node: MatterNode,
*,
user_index: int | None = None,
user_name: str | None = None,
user_unique_id: int | None = None,
user_status: str | None = None,
user_type: str | None = None,
credential_rule: str | None = None,
) -> SetLockUserResult:
"""Add or update a user on the lock.
When user_status, user_type, or credential_rule is None, defaults are used
for new users and existing values are preserved for modifications.
Returns typed dict with user_index on success.
Raises HomeAssistantError on failure.
"""
lock_endpoint = _get_lock_endpoint_or_raise(node)
_ensure_usr_support(lock_endpoint)
if user_index is None:
# Adding new user - find first available slot
max_users = (
lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.NumberOfTotalUsersSupported
)
or 0
)
for idx in range(1, max_users + 1):
get_user_response = await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.GetUser(userIndex=idx),
)
if _get_attr(get_user_response, "userStatus") is None:
user_index = idx
break
if user_index is None:
raise NoAvailableUserSlotsError("No available user slots on the lock")
user_status_enum = (
USER_STATUS_REVERSE_MAP.get(
user_status,
clusters.DoorLock.Enums.UserStatusEnum.kOccupiedEnabled,
)
if user_status is not None
else clusters.DoorLock.Enums.UserStatusEnum.kOccupiedEnabled
)
await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.SetUser(
operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd,
userIndex=user_index,
userName=user_name,
userUniqueID=user_unique_id,
userStatus=user_status_enum,
userType=USER_TYPE_REVERSE_MAP.get(
user_type,
clusters.DoorLock.Enums.UserTypeEnum.kUnrestrictedUser,
)
if user_type is not None
else clusters.DoorLock.Enums.UserTypeEnum.kUnrestrictedUser,
credentialRule=CREDENTIAL_RULE_REVERSE_MAP.get(
credential_rule,
clusters.DoorLock.Enums.CredentialRuleEnum.kSingle,
)
if credential_rule is not None
else clusters.DoorLock.Enums.CredentialRuleEnum.kSingle,
),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
)
else:
# Updating existing user - preserve existing values when not specified
get_user_response = await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.GetUser(userIndex=user_index),
)
if _get_attr(get_user_response, "userStatus") is None:
raise UserSlotEmptyError(f"User slot {user_index} is empty")
resolved_user_name = (
user_name
if user_name is not None
else _get_attr(get_user_response, "userName")
)
resolved_unique_id = (
user_unique_id
if user_unique_id is not None
else _get_attr(get_user_response, "userUniqueID")
)
resolved_status = (
USER_STATUS_REVERSE_MAP[user_status]
if user_status is not None
else _get_attr(get_user_response, "userStatus")
)
resolved_type = (
USER_TYPE_REVERSE_MAP[user_type]
if user_type is not None
else _get_attr(get_user_response, "userType")
)
resolved_rule = (
CREDENTIAL_RULE_REVERSE_MAP[credential_rule]
if credential_rule is not None
else _get_attr(get_user_response, "credentialRule")
)
await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.SetUser(
operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kModify,
userIndex=user_index,
userName=resolved_user_name,
userUniqueID=resolved_unique_id,
userStatus=resolved_status,
userType=resolved_type,
credentialRule=resolved_rule,
),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
)
return SetLockUserResult(user_index=user_index)
async def get_lock_users(
matter_client: MatterClient,
node: MatterNode,
) -> GetLockUsersResult:
"""Get all users from the lock.
Returns typed dict with users list and max_users capacity.
Raises HomeAssistantError on failure.
"""
lock_endpoint = _get_lock_endpoint_or_raise(node)
_ensure_usr_support(lock_endpoint)
max_users = (
lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.NumberOfTotalUsersSupported
)
or 0
)
users: list[LockUserData] = []
current_index = 1
# Iterate through users using next_user_index for efficiency
while current_index is not None and current_index <= max_users:
get_user_response = await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.GetUser(
userIndex=current_index,
),
)
user_data = _format_user_response(get_user_response)
if user_data is not None:
users.append(user_data)
# Move to next user index
next_index = _get_attr(get_user_response, "nextUserIndex")
if next_index is None or next_index <= current_index:
break
current_index = next_index
return GetLockUsersResult(
max_users=max_users,
users=users,
)
async def clear_lock_user(
matter_client: MatterClient,
node: MatterNode,
user_index: int,
) -> None:
"""Clear a user from the lock, cleaning up credentials first.
Use index 0xFFFE (CLEAR_ALL_INDEX) to clear all users.
Raises HomeAssistantError on failure.
"""
lock_endpoint = _get_lock_endpoint_or_raise(node)
_ensure_usr_support(lock_endpoint)
if user_index == CLEAR_ALL_INDEX:
# Clear all: clear all credentials first, then all users
await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.ClearCredential(
credential=None,
),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
)
else:
# Clear credentials for this specific user before deleting them
await _clear_user_credentials(
matter_client,
node.node_id,
lock_endpoint.endpoint_id,
user_index,
)
await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.ClearUser(
userIndex=user_index,
),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
)
# --- Credential validation helpers ---
# Map credential type strings to the feature bit that must be set
_CREDENTIAL_TYPE_FEATURE_MAP: dict[str, int] = {
CRED_TYPE_PIN: DoorLockFeature.kPinCredential,
CRED_TYPE_RFID: DoorLockFeature.kRfidCredential,
CRED_TYPE_FINGERPRINT: DoorLockFeature.kFingerCredentials,
CRED_TYPE_FINGER_VEIN: DoorLockFeature.kFingerCredentials,
CRED_TYPE_FACE: DoorLockFeature.kFaceCredentials,
}
def _validate_credential_type_support(
lock_endpoint: MatterEndpoint, credential_type: str
) -> None:
"""Validate the lock supports the requested credential type.
Raises CredentialTypeNotSupportedError if not supported.
"""
required_bit = _CREDENTIAL_TYPE_FEATURE_MAP.get(credential_type)
if required_bit is None:
raise CredentialTypeNotSupportedError(
translation_domain="matter",
translation_key=ERR_CREDENTIAL_TYPE_NOT_SUPPORTED,
translation_placeholders={"credential_type": credential_type},
)
feature_map = _get_feature_map(lock_endpoint) or 0
if not (feature_map & required_bit):
raise CredentialTypeNotSupportedError(
translation_domain="matter",
translation_key=ERR_CREDENTIAL_TYPE_NOT_SUPPORTED,
translation_placeholders={"credential_type": credential_type},
)
def _validate_credential_data(
lock_endpoint: MatterEndpoint, credential_type: str, credential_data: str
) -> None:
"""Validate credential data against lock constraints.
For PIN: checks digits-only and length against Min/MaxPINCodeLength.
For RFID: checks valid hex and byte length against Min/MaxRFIDCodeLength.
Raises CredentialDataInvalidError on failure.
"""
if credential_type == CRED_TYPE_PIN:
if not credential_data.isdigit():
raise CredentialDataInvalidError(
translation_domain="matter",
translation_key=ERR_INVALID_CREDENTIAL_DATA,
translation_placeholders={"reason": "PIN must contain only digits"},
)
min_len = (
lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.MinPINCodeLength
)
or 0
)
max_len = (
lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.MaxPINCodeLength
)
or 255
)
if not min_len <= len(credential_data) <= max_len:
raise CredentialDataInvalidError(
translation_domain="matter",
translation_key=ERR_INVALID_CREDENTIAL_DATA,
translation_placeholders={
"reason": (f"PIN length must be between {min_len} and {max_len}")
},
)
elif credential_type == CRED_TYPE_RFID:
try:
rfid_bytes = bytes.fromhex(credential_data)
except ValueError as err:
raise CredentialDataInvalidError(
translation_domain="matter",
translation_key=ERR_INVALID_CREDENTIAL_DATA,
translation_placeholders={
"reason": "RFID data must be valid hexadecimal"
},
) from err
min_len = (
lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.MinRFIDCodeLength
)
or 0
)
max_len = (
lock_endpoint.get_attribute_value(
None, clusters.DoorLock.Attributes.MaxRFIDCodeLength
)
or 255
)
if not min_len <= len(rfid_bytes) <= max_len:
raise CredentialDataInvalidError(
translation_domain="matter",
translation_key=ERR_INVALID_CREDENTIAL_DATA,
translation_placeholders={
"reason": (
f"RFID data length must be between"
f" {min_len} and {max_len} bytes"
)
},
)
def _credential_data_to_bytes(credential_type: str, credential_data: str) -> bytes:
"""Convert credential data string to bytes for the Matter command."""
if credential_type == CRED_TYPE_RFID:
return bytes.fromhex(credential_data)
# PIN and other types: encode as UTF-8
return credential_data.encode()
# --- Credential business logic functions ---
async def set_lock_credential(
matter_client: MatterClient,
node: MatterNode,
*,
credential_type: str,
credential_data: str,
credential_index: int | None = None,
user_index: int | None = None,
user_status: str | None = None,
user_type: str | None = None,
) -> SetLockCredentialResult:
"""Add or modify a credential on the lock.
Returns typed dict with credential_index, user_index, and next_credential_index.
Raises ServiceValidationError for validation failures.
Raises HomeAssistantError for device communication failures.
"""
lock_endpoint = _get_lock_endpoint_or_raise(node)
_ensure_usr_support(lock_endpoint)
_validate_credential_type_support(lock_endpoint, credential_type)
_validate_credential_data(lock_endpoint, credential_type, credential_data)
cred_type_int = CREDENTIAL_TYPE_REVERSE_MAP[credential_type]
cred_data_bytes = _credential_data_to_bytes(credential_type, credential_data)
# Determine operation type and credential index
operation_type = clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd
if credential_index is None:
# Auto-find first available credential slot
max_creds = (
lock_endpoint.get_attribute_value(
None,
clusters.DoorLock.Attributes.NumberOfCredentialsSupportedPerUser,
)
or 5
)
for idx in range(1, max_creds + 1):
status_response = await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=cred_type_int,
credentialIndex=idx,
),
),
)
if not _get_attr(status_response, "credentialExists"):
credential_index = idx
break
if credential_index is None:
raise NoAvailableUserSlotsError("No available credential slots on the lock")
else:
# Check if slot is occupied to determine Add vs Modify
status_response = await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=cred_type_int,
credentialIndex=credential_index,
),
),
)
if _get_attr(status_response, "credentialExists"):
operation_type = clusters.DoorLock.Enums.DataOperationTypeEnum.kModify
# Resolve optional user_status and user_type enums
resolved_user_status = (
USER_STATUS_REVERSE_MAP.get(user_status) if user_status is not None else None
)
resolved_user_type = (
USER_TYPE_REVERSE_MAP.get(user_type) if user_type is not None else None
)
set_cred_response = await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.SetCredential(
operationType=operation_type,
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=cred_type_int,
credentialIndex=credential_index,
),
credentialData=cred_data_bytes,
userIndex=user_index,
userStatus=resolved_user_status,
userType=resolved_user_type,
),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
)
status_code = _get_attr(set_cred_response, "status")
status_str = SET_CREDENTIAL_STATUS_MAP.get(status_code, f"unknown({status_code})")
if status_str != "success":
raise SetCredentialFailedError(
translation_domain="matter",
translation_key="set_credential_failed",
translation_placeholders={"status": status_str},
)
return SetLockCredentialResult(
credential_index=credential_index,
user_index=_get_attr(set_cred_response, "userIndex"),
next_credential_index=_get_attr(set_cred_response, "nextCredentialIndex"),
)
async def clear_lock_credential(
matter_client: MatterClient,
node: MatterNode,
*,
credential_type: str,
credential_index: int,
) -> None:
"""Clear a credential from the lock.
Raises HomeAssistantError on failure.
"""
lock_endpoint = _get_lock_endpoint_or_raise(node)
_ensure_usr_support(lock_endpoint)
cred_type_int = CREDENTIAL_TYPE_REVERSE_MAP[credential_type]
await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.ClearCredential(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=cred_type_int,
credentialIndex=credential_index,
),
),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
)
async def get_lock_credential_status(
matter_client: MatterClient,
node: MatterNode,
*,
credential_type: str,
credential_index: int,
) -> GetLockCredentialStatusResult:
"""Get the status of a credential slot on the lock.
Returns typed dict with credential_exists, user_index, next_credential_index.
Raises HomeAssistantError on failure.
"""
lock_endpoint = _get_lock_endpoint_or_raise(node)
_ensure_usr_support(lock_endpoint)
cred_type_int = CREDENTIAL_TYPE_REVERSE_MAP[credential_type]
response = await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=cred_type_int,
credentialIndex=credential_index,
),
),
)
return GetLockCredentialStatusResult(
credential_exists=bool(_get_attr(response, "credentialExists")),
user_index=_get_attr(response, "userIndex"),
next_credential_index=_get_attr(response, "nextCredentialIndex"),
)

View File

@@ -4,27 +4,11 @@ from __future__ import annotations
import voluptuous as vol
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.core import HomeAssistant, SupportsResponse, callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import (
ATTR_CREDENTIAL_DATA,
ATTR_CREDENTIAL_INDEX,
ATTR_CREDENTIAL_RULE,
ATTR_CREDENTIAL_TYPE,
ATTR_USER_INDEX,
ATTR_USER_NAME,
ATTR_USER_STATUS,
ATTR_USER_TYPE,
CLEAR_ALL_INDEX,
CREDENTIAL_RULE_REVERSE_MAP,
CREDENTIAL_TYPE_REVERSE_MAP,
DOMAIN,
SERVICE_CREDENTIAL_TYPES,
USER_TYPE_REVERSE_MAP,
)
from .const import DOMAIN
ATTR_DURATION = "duration"
ATTR_EMERGENCY_BOOST = "emergency_boost"
@@ -52,108 +36,3 @@ def async_setup_services(hass: HomeAssistant) -> None:
},
func="async_set_boost",
)
# Lock services - Full user CRUD
service.async_register_platform_entity_service(
hass,
DOMAIN,
"set_lock_user",
entity_domain=LOCK_DOMAIN,
schema={
vol.Optional(ATTR_USER_INDEX): vol.All(vol.Coerce(int), vol.Range(min=1)),
vol.Optional(ATTR_USER_NAME): vol.Any(str, None),
vol.Optional(ATTR_USER_TYPE): vol.In(USER_TYPE_REVERSE_MAP.keys()),
vol.Optional(ATTR_CREDENTIAL_RULE): vol.In(
CREDENTIAL_RULE_REVERSE_MAP.keys()
),
},
func="async_set_lock_user",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"clear_lock_user",
entity_domain=LOCK_DOMAIN,
schema={
vol.Required(ATTR_USER_INDEX): vol.All(
vol.Coerce(int),
vol.Any(vol.Range(min=1), CLEAR_ALL_INDEX),
),
},
func="async_clear_lock_user",
)
# Lock services - Query operations
service.async_register_platform_entity_service(
hass,
DOMAIN,
"get_lock_info",
entity_domain=LOCK_DOMAIN,
schema={},
func="async_get_lock_info",
supports_response=SupportsResponse.ONLY,
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"get_lock_users",
entity_domain=LOCK_DOMAIN,
schema={},
func="async_get_lock_users",
supports_response=SupportsResponse.ONLY,
)
# Lock services - Credential management
service.async_register_platform_entity_service(
hass,
DOMAIN,
"set_lock_credential",
entity_domain=LOCK_DOMAIN,
schema={
vol.Required(ATTR_CREDENTIAL_TYPE): vol.In(SERVICE_CREDENTIAL_TYPES),
vol.Required(ATTR_CREDENTIAL_DATA): str,
vol.Optional(ATTR_CREDENTIAL_INDEX): vol.All(
vol.Coerce(int), vol.Range(min=0)
),
vol.Optional(ATTR_USER_INDEX): vol.All(vol.Coerce(int), vol.Range(min=1)),
vol.Optional(ATTR_USER_STATUS): vol.In(
["occupied_enabled", "occupied_disabled"]
),
vol.Optional(ATTR_USER_TYPE): vol.In(USER_TYPE_REVERSE_MAP.keys()),
},
func="async_set_lock_credential",
supports_response=SupportsResponse.ONLY,
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"clear_lock_credential",
entity_domain=LOCK_DOMAIN,
schema={
vol.Required(ATTR_CREDENTIAL_TYPE): vol.In(SERVICE_CREDENTIAL_TYPES),
vol.Required(ATTR_CREDENTIAL_INDEX): vol.All(
vol.Coerce(int), vol.Range(min=0)
),
},
func="async_clear_lock_credential",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"get_lock_credential_status",
entity_domain=LOCK_DOMAIN,
schema={
vol.Required(ATTR_CREDENTIAL_TYPE): vol.In(
CREDENTIAL_TYPE_REVERSE_MAP.keys()
),
vol.Required(ATTR_CREDENTIAL_INDEX): vol.All(
vol.Coerce(int), vol.Range(min=0)
),
},
func="async_get_lock_credential_status",
supports_response=SupportsResponse.ONLY,
)

View File

@@ -1,177 +1,3 @@
clear_lock_credential:
target:
entity:
domain: lock
integration: matter
fields:
credential_type:
selector:
select:
options:
- pin
- rfid
- fingerprint
- finger_vein
- face
required: true
credential_index:
selector:
number:
min: 0
max: 65534
step: 1
mode: box
required: true
clear_lock_user:
target:
entity:
domain: lock
integration: matter
fields:
user_index:
selector:
number:
min: 1
max: 65534
step: 1
mode: box
required: true
get_lock_credential_status:
target:
entity:
domain: lock
integration: matter
fields:
credential_type:
selector:
select:
options:
- programming_pin
- pin
- rfid
- fingerprint
- finger_vein
- face
- aliro_credential_issuer_key
- aliro_evictable_endpoint_key
- aliro_non_evictable_endpoint_key
required: true
credential_index:
selector:
number:
min: 0
max: 65534
step: 1
mode: box
required: true
get_lock_info:
target:
entity:
domain: lock
integration: matter
get_lock_users:
target:
entity:
domain: lock
integration: matter
set_lock_credential:
target:
entity:
domain: lock
integration: matter
fields:
credential_type:
selector:
select:
options:
- pin
- rfid
- fingerprint
- finger_vein
- face
required: true
credential_data:
selector:
text:
required: true
credential_index:
selector:
number:
min: 0
max: 65534
step: 1
mode: box
user_index:
selector:
number:
min: 1
max: 65534
step: 1
mode: box
user_status:
selector:
select:
options:
- occupied_enabled
- occupied_disabled
user_type:
selector:
select:
options:
- unrestricted_user
- year_day_schedule_user
- week_day_schedule_user
- programming_user
- non_access_user
- forced_user
- disposable_user
- expiring_user
- schedule_restricted_user
- remote_only_user
set_lock_user:
target:
entity:
domain: lock
integration: matter
fields:
user_index:
selector:
number:
min: 1
max: 255
step: 1
mode: box
user_name:
selector:
text:
user_type:
selector:
select:
options:
- unrestricted_user
- year_day_schedule_user
- week_day_schedule_user
- programming_user
- non_access_user
- forced_user
- disposable_user
- expiring_user
- schedule_restricted_user
- remote_only_user
credential_rule:
selector:
select:
options:
- single
- dual
- tri
water_heater_boost:
target:
entity:

View File

@@ -619,17 +619,6 @@
}
}
},
"exceptions": {
"credential_type_not_supported": {
"message": "The lock does not support credential type `{credential_type}`."
},
"invalid_credential_data": {
"message": "Invalid credential data: {reason}."
},
"set_credential_failed": {
"message": "Failed to set credential: lock returned status `{status}`."
}
},
"issues": {
"server_version_version_too_new": {
"description": "The version of the Matter Server you are currently running is too new for this version of Home Assistant. Please update Home Assistant or downgrade the Matter Server to an older version to fix this issue.",
@@ -641,52 +630,6 @@
}
},
"services": {
"clear_lock_credential": {
"description": "Removes a credential from the lock.",
"fields": {
"credential_index": {
"description": "The credential slot index to clear.",
"name": "Credential index"
},
"credential_type": {
"description": "The type of credential to clear.",
"name": "Credential type"
}
},
"name": "Clear lock credential"
},
"clear_lock_user": {
"description": "Deletes a lock user and all associated credentials. Use index 65534 to clear all users.",
"fields": {
"user_index": {
"description": "The user slot index (1-based) to clear, or 65534 to clear all.",
"name": "User index"
}
},
"name": "Clear lock user"
},
"get_lock_credential_status": {
"description": "Returns the status of a credential slot on the lock.",
"fields": {
"credential_index": {
"description": "The credential slot index to query.",
"name": "Credential index"
},
"credential_type": {
"description": "The type of credential to query.",
"name": "Credential type"
}
},
"name": "Get lock credential status"
},
"get_lock_info": {
"description": "Returns lock capabilities including supported credential types, user capacity, and PIN length constraints.",
"name": "Get lock info"
},
"get_lock_users": {
"description": "Returns all users configured on the lock with their credentials.",
"name": "Get lock users"
},
"open_commissioning_window": {
"description": "Allows adding one of your devices to another Matter network by opening the commissioning window for this Matter device for 60 seconds.",
"fields": {
@@ -697,58 +640,6 @@
},
"name": "Open commissioning window"
},
"set_lock_credential": {
"description": "Adds or updates a credential on the lock.",
"fields": {
"credential_data": {
"description": "The credential data. For PIN: digits only. For RFID: hexadecimal string.",
"name": "Credential data"
},
"credential_index": {
"description": "The credential slot index. Leave empty to auto-find an available slot.",
"name": "Credential index"
},
"credential_type": {
"description": "The type of credential (e.g., pin, rfid, fingerprint).",
"name": "Credential type"
},
"user_index": {
"description": "The user index to associate the credential with. Leave empty for automatic assignment.",
"name": "User index"
},
"user_status": {
"description": "The user status to set when creating a new user for this credential.",
"name": "User status"
},
"user_type": {
"description": "The user type to set when creating a new user for this credential.",
"name": "User type"
}
},
"name": "Set lock credential"
},
"set_lock_user": {
"description": "Creates or updates a lock user.",
"fields": {
"credential_rule": {
"description": "The credential rule for the user.",
"name": "Credential rule"
},
"user_index": {
"description": "The user slot index (1-based). Leave empty to auto-find an available slot.",
"name": "User index"
},
"user_name": {
"description": "The name for the user.",
"name": "User name"
},
"user_type": {
"description": "The type of user to create.",
"name": "User type"
}
},
"name": "Set lock user"
},
"water_heater_boost": {
"description": "Enables water heater boost for a specific duration.",
"fields": {

View File

@@ -168,9 +168,8 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
segments: dict[str, Segment] = {}
for area in supported_areas:
area_name = None
location_info = area.areaInfo.locationInfo
if location_info not in (None, clusters.NullValue):
area_name = location_info.locationName
if area.areaInfo and area.areaInfo.locationInfo:
area_name = area.areaInfo.locationInfo.locationName
if area_name:
segment_id = str(area.areaID)

View File

@@ -128,7 +128,6 @@ class MonopriceZone(MediaPlayerEntity):
)
_attr_has_entity_name = True
_attr_name = None
_attr_volume_step = 1 / MAX_VOLUME
def __init__(self, monoprice, sources, namespace, zone_id):
"""Initialize new zone."""
@@ -212,3 +211,17 @@ class MonopriceZone(MediaPlayerEntity):
def set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
self._monoprice.set_volume(self._zone_id, round(volume * MAX_VOLUME))
def volume_up(self) -> None:
"""Volume up the media player."""
if self.volume_level is None:
return
volume = round(self.volume_level * MAX_VOLUME)
self._monoprice.set_volume(self._zone_id, min(volume + 1, MAX_VOLUME))
def volume_down(self) -> None:
"""Volume down media player."""
if self.volume_level is None:
return
volume = round(self.volume_level * MAX_VOLUME)
self._monoprice.set_volume(self._zone_id, max(volume - 1, 0))

View File

@@ -93,7 +93,6 @@ class MpdDevice(MediaPlayerEntity):
_attr_media_content_type = MediaType.MUSIC
_attr_has_entity_name = True
_attr_name = None
_attr_volume_step = 0.05
def __init__(
self, server: str, port: int, password: str | None, unique_id: str
@@ -394,6 +393,24 @@ class MpdDevice(MediaPlayerEntity):
if "volume" in self._status:
await self._client.setvol(int(volume * 100))
async def async_volume_up(self) -> None:
"""Service to send the MPD the command for volume up."""
async with self.connection():
if "volume" in self._status:
current_volume = int(self._status["volume"])
if current_volume <= 100:
self._client.setvol(current_volume + 5)
async def async_volume_down(self) -> None:
"""Service to send the MPD the command for volume down."""
async with self.connection():
if "volume" in self._status:
current_volume = int(self._status["volume"])
if current_volume >= 0:
await self._client.setvol(current_volume - 5)
async def async_media_play(self) -> None:
"""Service to send the MPD the command for play/pause."""
async with self.connection():

View File

@@ -107,6 +107,7 @@ ABBREVIATIONS = {
"modes": "modes",
"name": "name",
"o": "origin",
"obj_id": "object_id",
"off_dly": "off_delay",
"on_cmd_type": "on_command_type",
"ops": "options",

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