Compare commits

..

3 Commits

Author SHA1 Message Date
Marc Mueller
a2140bd033 Build netifaces 0.11.0 2025-10-19 14:24:12 +02:00
Marc Mueller
133054693e Build Python 3.14 wheels 2025-10-19 14:24:12 +02:00
Marc Mueller
f695fa182c Only build custom wheels 2025-10-19 14:23:54 +02:00
422 changed files with 3578 additions and 19004 deletions

View File

@@ -33,7 +33,7 @@
"GitHub.vscode-pull-request-github",
"GitHub.copilot"
],
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.jsonc
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json
"settings": {
"python.experiments.optOutFrom": ["pythonTestAdapter"],
"python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python",
@@ -41,7 +41,6 @@
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment",
"python.analysis.typeCheckingMode": "basic",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
@@ -63,9 +62,6 @@
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
},
"[json][jsonc][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"json.schemas": [
{
"fileMatch": ["homeassistant/components/*/manifest.json"],

View File

@@ -74,7 +74,6 @@ rules:
- **Formatting**: Ruff
- **Linting**: PyLint and Ruff
- **Type Checking**: MyPy
- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists
- **Testing**: pytest with plain functions and fixtures
- **Language**: American English for all code, comments, and documentation (use sentence case, including titles)

View File

@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 1
CACHE_VERSION: 9
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.11"

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
with:
category: "/language:python"

View File

@@ -80,6 +80,8 @@ jobs:
# Add additional pip wheel build constraints
echo "PIP_CONSTRAINT=build_constraints.txt"
echo 'CFLAGS="-Wno-error=int-conversion"'
) > .env_file
- name: Write pip wheel build constraints
@@ -126,13 +128,13 @@ jobs:
core:
name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
if: github.repository_owner == 'home-assistant'
if: false && github.repository_owner == 'home-assistant'
needs: init
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix: &matrix-build
abi: ["cp313", "cp314"]
abi: ["cp314"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
include:
- os: ubuntu-latest
@@ -219,9 +221,29 @@ jobs:
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
- name: Create requirements file for custom build
run: |
touch requirements_custom.txt
echo "netifaces==0.11.0" >> requirements_custom.txt
- name: Build wheels (custom)
uses: cdce8p/wheels@master
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements: "requirements_custom.txt"
verbose: true
# home-assistant/wheels doesn't support sha pinning
- name: Build wheels
uses: *home-assistant-wheels
if: false
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2

2
.gitignore vendored
View File

@@ -111,7 +111,6 @@ virtualization/vagrant/config
!.vscode/cSpell.json
!.vscode/extensions.json
!.vscode/tasks.json
!.vscode/settings.default.jsonc
.env
# Windows Explorer
@@ -141,5 +140,4 @@ pytest_buckets.txt
# AI tooling
.claude/settings.local.json
.serena/

View File

@@ -278,7 +278,6 @@ homeassistant.components.imap.*
homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.inels.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*
@@ -478,7 +477,6 @@ homeassistant.components.skybell.*
homeassistant.components.slack.*
homeassistant.components.sleep_as_android.*
homeassistant.components.sleepiq.*
homeassistant.components.sma.*
homeassistant.components.smhi.*
homeassistant.components.smlight.*
homeassistant.components.smtp.*

View File

@@ -7,19 +7,13 @@
"python.testing.pytestEnabled": false,
// https://code.visualstudio.com/docs/python/linting#_general-settings
"pylint.importStrategy": "fromEnvironment",
// Pyright is too pedantic for Home Assistant
"python.analysis.typeCheckingMode": "basic",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
},
"[json][jsonc][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"json.schemas": [
{
"fileMatch": ["homeassistant/components/*/manifest.json"],
// This value differs between working with devcontainer and locally, therefore this value should NOT be in sync!
"url": "./script/json_schemas/manifest_schema.json"
}
]
{
"fileMatch": [
"homeassistant/components/*/manifest.json"
],
// This value differs between working with devcontainer and locally, therefor this value should NOT be in sync!
"url": "./script/json_schemas/manifest_schema.json"
}
]
}

4
CODEOWNERS generated
View File

@@ -494,8 +494,6 @@ build.json @home-assistant/supervisor
/tests/components/filesize/ @gjohansson-ST
/homeassistant/components/filter/ @dgomes
/tests/components/filter/ @dgomes
/homeassistant/components/fing/ @Lorenzo-Gasparini
/tests/components/fing/ @Lorenzo-Gasparini
/homeassistant/components/firefly_iii/ @erwindouna
/tests/components/firefly_iii/ @erwindouna
/homeassistant/components/fireservicerota/ @cyberjunky
@@ -743,8 +741,6 @@ build.json @home-assistant/supervisor
/tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh
/tests/components/incomfort/ @jbouwh
/homeassistant/components/inels/ @epdevlab
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01
/homeassistant/components/inkbird/ @bdraco

4
Dockerfile generated
View File

@@ -25,13 +25,13 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
# Install uv
RUN pip3 install uv==0.9.5
RUN pip3 install uv==0.8.9
WORKDIR /usr/src

View File

@@ -5,6 +5,9 @@ build_from:
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
cosign:
base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.*

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["airos==0.6.0"]
"requirements": ["airos==0.5.6"]
}

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==1.0.2"]
"requirements": ["aioairzone==1.0.1"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==6.4.6"]
"requirements": ["aioamazondevices==6.4.4"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["bring_api"],
"quality_scale": "platinum",
"requirements": ["bring-api==1.1.1"]
"requirements": ["bring-api==1.1.0"]
}

View File

@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.15.0"]
"requirements": ["bthome-ble==3.14.2"]
}

View File

@@ -816,20 +816,13 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
return MediaPlayerState.PAUSED
if media_status.player_is_idle:
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
if self.app_id is not None and self.app_id != pychromecast.IDLE_APP_ID:
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
return MediaPlayerState.IDLE
if self._chromecast is not None and self._chromecast.is_idle:
return MediaPlayerState.OFF
return None
@property

View File

@@ -5,7 +5,7 @@ from aiocomelit.const import BRIDGE
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from .const import CONF_VEDO_PIN, DEFAULT_PORT
from .const import DEFAULT_PORT
from .coordinator import (
ComelitBaseCoordinator,
ComelitConfigEntry,
@@ -43,13 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b
entry.data[CONF_HOST],
entry.data.get(CONF_PORT, DEFAULT_PORT),
entry.data[CONF_PIN],
entry.data.get(CONF_VEDO_PIN),
session,
)
platforms = list(BRIDGE_PLATFORMS)
# Add VEDO platforms if vedo_pin is configured
if entry.data.get(CONF_VEDO_PIN):
platforms.extend(VEDO_PLATFORMS)
platforms = BRIDGE_PLATFORMS
else:
coordinator = ComelitVedoSystem(
hass,
@@ -74,10 +70,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) ->
"""Unload a config entry."""
if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
platforms = list(BRIDGE_PLATFORMS)
# Add VEDO platforms if vedo_pin was configured
if entry.data.get(CONF_VEDO_PIN):
platforms.extend(VEDO_PLATFORMS)
platforms = BRIDGE_PLATFORMS
else:
platforms = VEDO_PLATFORMS

View File

@@ -6,7 +6,7 @@ import logging
from typing import cast
from aiocomelit.api import ComelitVedoAreaObject
from aiocomelit.const import BRIDGE, AlarmAreaState
from aiocomelit.const import AlarmAreaState
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@@ -14,13 +14,11 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
CodeFormat,
)
from homeassistant.const import CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
from .utils import DeviceType, alarm_device_listener
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -58,34 +56,12 @@ async def async_setup_entry(
) -> None:
"""Set up the Comelit VEDO system alarm control panel devices."""
if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
# Only setup if bridge has VEDO alarm enabled
if not coordinator.vedo_pin:
return
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitBridgeAlarmEntity(coordinator, device, config_entry.entry_id)
for device in (coordinator.alarm_data or {})
.get("alarm_areas", {})
.values()
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
alarm_device_listener(coordinator, _add_new_entities, "alarm_areas")
)
else:
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
async_add_entities(
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_areas"].values()
)
async_add_entities(
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_areas"].values()
)
class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanelEntity):
@@ -195,133 +171,3 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
)
class ComelitBridgeAlarmEntity(
CoordinatorEntity[ComelitSerialBridge], AlarmControlPanelEntity
):
"""Representation of a VEDO alarm panel on a Serial Bridge."""
_attr_has_entity_name = True
_attr_name = None
_attr_code_format = CodeFormat.NUMBER
_attr_code_arm_required = False
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.ARM_HOME
)
def __init__(
self,
coordinator: ComelitSerialBridge,
area: ComelitVedoAreaObject,
config_entry_entry_id: str,
) -> None:
"""Initialize the alarm panel."""
self._area_index = area.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{area.index}"
self._attr_device_info = coordinator.platform_device_info(area, "area")
if area.p2:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_NIGHT
@property
def _area(self) -> ComelitVedoAreaObject:
"""Return area object."""
if self.coordinator.alarm_data:
return self.coordinator.alarm_data["alarm_areas"][self._area_index]
# Return a default area object if no alarm data
return ComelitVedoAreaObject(
index=self._area_index,
name="Unknown",
p1=False,
p2=False,
ready=False,
armed=0,
alarm=False,
alarm_memory=False,
sabotage=False,
anomaly=False,
in_time=False,
out_time=False,
human_status=AlarmAreaState.UNKNOWN,
)
@property
def available(self) -> bool:
"""Return True if alarm is available."""
if not self.coordinator.alarm_data:
return False
if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]:
return False
return super().available
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the state of the alarm."""
_LOGGER.debug(
"Area %s status is: %s. Armed is %s",
self._area.name,
self._area.human_status,
self._area.armed,
)
if self._area.human_status == AlarmAreaState.ARMED:
if self._area.armed == ALARM_AREA_ARMED_STATUS[AWAY]:
return AlarmControlPanelState.ARMED_AWAY
if self._area.armed == ALARM_AREA_ARMED_STATUS[NIGHT]:
return AlarmControlPanelState.ARMED_NIGHT
return AlarmControlPanelState.ARMED_HOME
return {
AlarmAreaState.DISARMED: AlarmControlPanelState.DISARMED,
AlarmAreaState.ENTRY_DELAY: AlarmControlPanelState.DISARMING,
AlarmAreaState.EXIT_DELAY: AlarmControlPanelState.ARMING,
AlarmAreaState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
}.get(self._area.human_status)
async def _async_update_state(self, area_state: AlarmAreaState, armed: int) -> None:
"""Update state after action."""
self._area.human_status = area_state
self._area.armed = armed
await self.async_update_ha_state()
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
if code != str(self.coordinator.vedo_pin):
return
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[DISABLE]
)
await self._async_update_state(
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
)
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[AWAY]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[HOME]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
)
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[NIGHT]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
)

View File

@@ -5,19 +5,17 @@ from __future__ import annotations
from typing import cast
from aiocomelit import ComelitVedoZoneObject
from aiocomelit.const import BRIDGE
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
from .utils import DeviceType, alarm_device_listener, new_device_listener
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
from .utils import DeviceType, new_device_listener
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -30,47 +28,21 @@ async def async_setup_entry(
) -> None:
"""Set up Comelit VEDO presence sensors."""
if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
# Only setup if bridge has VEDO alarm enabled
if not coordinator.vedo_pin:
return
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoBridgeBinarySensorEntity(
coordinator, device, config_entry.entry_id
)
for device in (coordinator.alarm_data or {})
.get("alarm_zones", {})
.values()
if device in new_devices
]
if entities:
async_add_entities(entities)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_zones"].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
alarm_device_listener(coordinator, _add_new_entities, "alarm_zones")
)
else:
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoBinarySensorEntity(
coordinator, device, config_entry.entry_id
)
for device in coordinator.data["alarm_zones"].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
)
class ComelitVedoBinarySensorEntity(
@@ -101,41 +73,3 @@ class ComelitVedoBinarySensorEntity(
return (
self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001"
)
class ComelitVedoBridgeBinarySensorEntity(
CoordinatorEntity[ComelitSerialBridge], BinarySensorEntity
):
"""VEDO sensor device on a Serial Bridge."""
_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.MOTION
def __init__(
self,
coordinator: ComelitSerialBridge,
zone: ComelitVedoZoneObject,
config_entry_entry_id: str,
) -> None:
"""Init sensor entity."""
self._zone_index = zone.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}"
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
@property
def available(self) -> bool:
"""Sensor availability."""
return self.coordinator.alarm_data is not None
@property
def is_on(self) -> bool:
"""Presence detected."""
if not self.coordinator.alarm_data:
return False
return (
self.coordinator.alarm_data["alarm_zones"][self._zone_index].status_api
== "0001"
)

View File

@@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
from .utils import async_client_session
DEFAULT_HOST = "192.168.1.252"
@@ -34,7 +34,6 @@ USER_SCHEMA = vol.Schema(
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
vol.Optional(CONF_VEDO_PIN): cv.string,
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
@@ -43,7 +42,6 @@ STEP_RECONFIGURE = vol.Schema(
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
vol.Optional(CONF_VEDO_PIN): cv.string,
}
)
@@ -81,27 +79,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
finally:
await api.logout()
# Validate VEDO PIN if provided and device type is BRIDGE
if data.get(CONF_VEDO_PIN) and data.get(CONF_TYPE, BRIDGE) == BRIDGE:
if not re.fullmatch(r"[0-9]{4,10}", data[CONF_VEDO_PIN]):
raise InvalidVedoPin
# Verify VEDO is enabled with the provided PIN
try:
if not await api.vedo_enabled(data[CONF_VEDO_PIN]):
raise InvalidVedoAuth
except (aiocomelit_exceptions.CannotConnect, TimeoutError) as err:
raise CannotConnect(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except aiocomelit_exceptions.CannotAuthenticate:
raise InvalidVedoAuth(
translation_domain=DOMAIN,
translation_key="invalid_vedo_auth",
) from None
return {"title": data[CONF_HOST]}
@@ -129,10 +106,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except InvalidVedoPin:
errors["base"] = "invalid_vedo_pin"
except InvalidVedoAuth:
errors["base"] = "invalid_vedo_auth"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -214,38 +187,19 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
try:
data_to_validate = {
CONF_HOST: updated_host,
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
}
if CONF_VEDO_PIN in user_input:
data_to_validate[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
await validate_input(self.hass, data_to_validate)
await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except InvalidVedoPin:
errors["base"] = "invalid_vedo_pin"
except InvalidVedoAuth:
errors["base"] = "invalid_vedo_auth"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
data_updates = {
CONF_HOST: updated_host,
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
}
if CONF_VEDO_PIN in user_input:
data_updates[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
return self.async_update_reload_and_abort(
reconfigure_entry, data_updates=data_updates
reconfigure_entry, data_updates={CONF_HOST: updated_host}
)
return self.async_show_form(
@@ -265,11 +219,3 @@ class InvalidAuth(HomeAssistantError):
class InvalidPin(HomeAssistantError):
"""Error to indicate an invalid pin."""
class InvalidVedoPin(HomeAssistantError):
"""Error to indicate an invalid VEDO pin."""
class InvalidVedoAuth(HomeAssistantError):
"""Error to indicate VEDO authentication failed."""

View File

@@ -9,7 +9,6 @@ _LOGGER = logging.getLogger(__package__)
DOMAIN = "comelit"
DEFAULT_PORT = 80
DEVICE_TYPE_LIST = [BRIDGE, VEDO]
CONF_VEDO_PIN = "vedo_pin"
SCAN_INTERVAL = 5

View File

@@ -154,8 +154,6 @@ class ComelitSerialBridge(
_hw_version = "20003101"
api: ComeliteSerialBridgeApi
vedo_pin: str | None
alarm_data: AlarmDataObject | None = None
def __init__(
self,
@@ -164,49 +162,25 @@ class ComelitSerialBridge(
host: str,
port: int,
pin: str,
vedo_pin: str | None,
session: ClientSession,
) -> None:
"""Initialize the scanner."""
self.api = ComeliteSerialBridgeApi(host, port, pin, session)
self.vedo_pin = vedo_pin
super().__init__(hass, entry, BRIDGE, host)
async def _async_update_system_data(
self,
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
"""Specific method for updating data."""
devices = await self.api.get_all_devices()
data = await self.api.get_all_devices()
if self.data:
for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO):
await self._async_remove_stale_devices(
self.data[dev_type], devices[dev_type], dev_type
self.data[dev_type], data[dev_type], dev_type
)
# Get VEDO alarm data if vedo_pin is configured
if self.vedo_pin:
try:
if await self.api.vedo_enabled(self.vedo_pin):
self.alarm_data = await self.api.get_all_areas_and_zones()
# Remove stale alarm devices
if self.alarm_data:
previous_alarm_data = getattr(
self, "_previous_alarm_data", None
)
if previous_alarm_data:
for obj_type in ("alarm_areas", "alarm_zones"):
await self._async_remove_stale_devices(
previous_alarm_data[obj_type],
self.alarm_data[obj_type],
"area" if obj_type == "alarm_areas" else "zone",
)
self._previous_alarm_data = self.alarm_data
except (CannotAuthenticate, CannotConnect, CannotRetrieveData):
_LOGGER.warning("Failed to retrieve VEDO alarm data")
return devices
return data
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):

View File

@@ -20,7 +20,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
from .entity import ComelitBridgeBaseEntity
from .utils import DeviceType, alarm_device_listener, new_device_listener
from .utils import DeviceType, new_device_listener
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -83,30 +83,6 @@ async def async_setup_bridge_entry(
new_device_listener(coordinator, _add_new_entities, OTHER)
)
# Add VEDO sensors if bridge has alarm data
if coordinator.vedo_pin:
def _add_new_alarm_entities(
new_devices: list[DeviceType], dev_type: str
) -> None:
"""Add entities for new alarm zones."""
entities = [
ComelitVedoBridgeSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_VEDO_TYPES
for device in (coordinator.alarm_data or {})
.get("alarm_zones", {})
.values()
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
alarm_device_listener(coordinator, _add_new_alarm_entities, "alarm_zones")
)
async def async_setup_vedo_entry(
hass: HomeAssistant,
@@ -203,58 +179,3 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
return None
return cast(str, status.value)
class ComelitVedoBridgeSensorEntity(
CoordinatorEntity[ComelitSerialBridge], SensorEntity
):
"""VEDO sensor device on a Serial Bridge."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: ComelitSerialBridge,
zone: ComelitVedoZoneObject,
config_entry_entry_id: str,
description: SensorEntityDescription,
) -> None:
"""Init sensor entity."""
self._zone_index = zone.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{zone.index}"
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
self.entity_description = description
@property
def _zone_object(self) -> ComelitVedoZoneObject:
"""Zone object."""
if self.coordinator.alarm_data:
return self.coordinator.alarm_data["alarm_zones"][self._zone_index]
# Return a default zone object if no alarm data
return ComelitVedoZoneObject(
index=self._zone_index,
name="Unknown",
status_api="0x000",
status=0,
human_status=AlarmZoneState.UNAVAILABLE,
)
@property
def available(self) -> bool:
"""Sensor availability."""
return (
self.coordinator.alarm_data is not None
and self._zone_object.human_status != AlarmZoneState.UNAVAILABLE
)
@property
def native_value(self) -> StateType:
"""Sensor value."""
if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN:
return None
return cast(str, status.value)

View File

@@ -15,29 +15,25 @@
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"pin": "[%key:common::config_flow::data::pin%]",
"type": "Device type",
"vedo_pin": "VEDO alarm PIN (optional)"
"type": "Device type"
},
"data_description": {
"host": "The hostname or IP address of your Comelit device.",
"port": "The port of your Comelit device.",
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
"type": "The type of your Comelit device.",
"vedo_pin": "Optional PIN for VEDO alarm system on Serial Bridge devices. Leave empty if you don't have VEDO alarm enabled."
"type": "The type of your Comelit device."
}
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"pin": "[%key:common::config_flow::data::pin%]",
"vedo_pin": "[%key:component::comelit::config::step::user::data::vedo_pin%]"
"pin": "[%key:common::config_flow::data::pin%]"
},
"data_description": {
"host": "[%key:component::comelit::config::step::user::data_description::host%]",
"port": "[%key:component::comelit::config::step::user::data_description::port%]",
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
"vedo_pin": "[%key:component::comelit::config::step::user::data_description::vedo_pin%]"
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]"
}
}
},
@@ -48,16 +44,12 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "The provided PIN is invalid. It must be a 4-10 digit number.",
"invalid_vedo_pin": "The provided VEDO PIN is invalid. It must be a 4-10 digit number.",
"invalid_vedo_auth": "The provided VEDO PIN is incorrect or VEDO alarm is not enabled on this device.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "[%key:component::comelit::config::abort::invalid_pin%]",
"invalid_vedo_pin": "[%key:component::comelit::config::abort::invalid_vedo_pin%]",
"invalid_vedo_auth": "[%key:component::comelit::config::abort::invalid_vedo_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},

View File

@@ -158,35 +158,3 @@ def new_device_listener(
_check_devices()
return coordinator.async_add_listener(_check_devices)
def alarm_device_listener(
coordinator: ComelitBaseCoordinator,
new_devices_callback: Callable[
[list[ComelitVedoAreaObject | ComelitVedoZoneObject], str],
None,
],
data_type: str,
) -> Callable[[], None]:
"""Subscribe to coordinator updates to check for new alarm devices on bridge."""
known_devices: dict[str, list[int]] = {}
def _check_alarm_devices() -> None:
"""Check for new alarm devices and call callback with any new devices."""
# For ComelitSerialBridge with alarm_data
if not hasattr(coordinator, "alarm_data") or not coordinator.alarm_data:
return
new_devices: list[ComelitVedoAreaObject | ComelitVedoZoneObject] = []
for _id in coordinator.alarm_data[data_type]:
if _id not in (id_list := known_devices.get(data_type, [])):
known_devices.update({data_type: [*id_list, _id]})
new_devices.append(coordinator.alarm_data[data_type][_id])
if new_devices:
new_devices_callback(new_devices, data_type)
# Check for devices immediately
_check_alarm_devices()
return coordinator.async_add_listener(_check_alarm_devices)

View File

@@ -34,7 +34,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER]
@dataclass

View File

@@ -1,301 +0,0 @@
"""Platform for Control4 Climate/Thermostat."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pyControl4.climate import C4Climate
from pyControl4.error_handling import C4Exception
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category
from .const import CONTROL4_ENTITY_TYPE
from .director_utils import update_variables_for_config_entry
from .entity import Control4Entity
_LOGGER = logging.getLogger(__name__)
CONTROL4_CATEGORY = "comfort"
# Control4 variable names
CONTROL4_HVAC_STATE = "HVAC_STATE"
CONTROL4_HVAC_MODE = "HVAC_MODE"
CONTROL4_CURRENT_TEMPERATURE = "TEMPERATURE_F"
CONTROL4_HUMIDITY = "HUMIDITY"
CONTROL4_COOL_SETPOINT = "COOL_SETPOINT_F"
CONTROL4_HEAT_SETPOINT = "HEAT_SETPOINT_F"
VARIABLES_OF_INTEREST = {
CONTROL4_HVAC_STATE,
CONTROL4_HVAC_MODE,
CONTROL4_CURRENT_TEMPERATURE,
CONTROL4_HUMIDITY,
CONTROL4_COOL_SETPOINT,
CONTROL4_HEAT_SETPOINT,
}
# Map Control4 HVAC modes to Home Assistant
C4_TO_HA_HVAC_MODE = {
"Off": HVACMode.OFF,
"Cool": HVACMode.COOL,
"Heat": HVACMode.HEAT,
"Auto": HVACMode.HEAT_COOL,
}
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
# Map Control4 HVAC state to Home Assistant HVAC action
C4_TO_HA_HVAC_ACTION = {
"heating": HVACAction.HEATING,
"cooling": HVACAction.COOLING,
"idle": HVACAction.IDLE,
"off": HVACAction.OFF,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: Control4ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Control4 thermostats from a config entry."""
runtime_data = entry.runtime_data
async def async_update_data() -> dict[int, dict[str, Any]]:
"""Fetch data from Control4 director for thermostats."""
try:
return await update_variables_for_config_entry(
hass, entry, VARIABLES_OF_INTEREST
)
except C4Exception as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
hass,
_LOGGER,
name="climate",
update_method=async_update_data,
update_interval=timedelta(seconds=runtime_data.scan_interval),
config_entry=entry,
)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY)
entity_list = []
for item in items_of_category:
try:
if item["type"] == CONTROL4_ENTITY_TYPE:
item_name = item["name"]
item_id = item["id"]
item_parent_id = item["parentId"]
item_manufacturer = None
item_device_name = None
item_model = None
for parent_item in items_of_category:
if parent_item["id"] == item_parent_id:
item_manufacturer = parent_item.get("manufacturer")
item_device_name = parent_item.get("roomName")
item_model = parent_item.get("model")
else:
continue
except KeyError:
_LOGGER.exception(
"Unknown device properties received from Control4: %s",
item,
)
continue
# Skip if we don't have data for this thermostat
if item_id not in coordinator.data:
_LOGGER.warning(
"Couldn't get climate state data for %s (ID: %s), skipping setup",
item_name,
item_id,
)
continue
entity_list.append(
Control4Climate(
runtime_data,
coordinator,
item_name,
item_id,
item_device_name,
item_manufacturer,
item_model,
item_parent_id,
)
)
async_add_entities(entity_list)
class Control4Climate(Control4Entity, ClimateEntity):
"""Control4 climate entity."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL]
def __init__(
self,
runtime_data: Control4RuntimeData,
coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]],
name: str,
idx: int,
device_name: str | None,
device_manufacturer: str | None,
device_model: str | None,
device_id: int,
) -> None:
"""Initialize Control4 climate entity."""
super().__init__(
runtime_data,
coordinator,
name,
idx,
device_name,
device_manufacturer,
device_model,
device_id,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._thermostat_data is not None
def _create_api_object(self) -> C4Climate:
"""Create a pyControl4 device object.
This exists so the director token used is always the latest one, without needing to re-init the entire entity.
"""
return C4Climate(self.runtime_data.director, self._idx)
@property
def _thermostat_data(self) -> dict[str, Any] | None:
"""Return the thermostat data from the coordinator."""
return self.coordinator.data.get(self._idx)
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
data = self._thermostat_data
if data is None:
return None
return data.get(CONTROL4_CURRENT_TEMPERATURE)
@property
def current_humidity(self) -> int | None:
"""Return the current humidity."""
data = self._thermostat_data
if data is None:
return None
humidity = data.get(CONTROL4_HUMIDITY)
return int(humidity) if humidity is not None else None
@property
def hvac_mode(self) -> HVACMode:
"""Return current HVAC mode."""
data = self._thermostat_data
if data is None:
return HVACMode.OFF
c4_mode = data.get(CONTROL4_HVAC_MODE) or ""
return C4_TO_HA_HVAC_MODE.get(c4_mode, HVACMode.OFF)
@property
def hvac_action(self) -> HVACAction | None:
"""Return current HVAC action."""
data = self._thermostat_data
if data is None:
return None
c4_state = data.get(CONTROL4_HVAC_STATE)
if c4_state is None:
return None
# Convert state to lowercase for mapping
return C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
data = self._thermostat_data
if data is None:
return None
hvac_mode = self.hvac_mode
if hvac_mode == HVACMode.COOL:
return data.get(CONTROL4_COOL_SETPOINT)
if hvac_mode == HVACMode.HEAT:
return data.get(CONTROL4_HEAT_SETPOINT)
return None
@property
def target_temperature_high(self) -> float | None:
"""Return the high target temperature for auto mode."""
data = self._thermostat_data
if data is None:
return None
if self.hvac_mode == HVACMode.HEAT_COOL:
return data.get(CONTROL4_COOL_SETPOINT)
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the low target temperature for auto mode."""
data = self._thermostat_data
if data is None:
return None
if self.hvac_mode == HVACMode.HEAT_COOL:
return data.get(CONTROL4_HEAT_SETPOINT)
return None
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target HVAC mode."""
c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode]
c4_climate = self._create_api_object()
await c4_climate.setHvacMode(c4_hvac_mode)
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
c4_climate = self._create_api_object()
low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
temp = kwargs.get(ATTR_TEMPERATURE)
# Handle temperature range for auto mode
if self.hvac_mode == HVACMode.HEAT_COOL:
if low_temp is not None:
await c4_climate.setHeatSetpointF(low_temp)
if high_temp is not None:
await c4_climate.setCoolSetpointF(high_temp)
# Handle single temperature setpoint
elif temp is not None:
if self.hvac_mode == HVACMode.COOL:
await c4_climate.setCoolSetpointF(temp)
elif self.hvac_mode == HVACMode.HEAT:
await c4_climate.setHeatSetpointF(temp)
await self.coordinator.async_request_refresh()

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["pycync==0.4.2"]
"requirements": ["pycync==0.4.1"]
}

View File

@@ -80,7 +80,8 @@ async def async_setup_entry(
)
class DevoloScannerEntity(
# The pylint disable is needed because of https://github.com/pylint-dev/pylint/issues/9138
class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
CoordinatorEntity[DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]]],
ScannerEntity,
):

View File

@@ -122,12 +122,10 @@ class WanIpSensor(SensorEntity):
try:
async with asyncio.timeout(10):
response = await self.resolver.query(self.hostname, self.querytype)
except TimeoutError as err:
_LOGGER.debug("Timeout while resolving host: %s", err)
except TimeoutError:
await self.resolver.close()
except DNSError as err:
_LOGGER.warning("Exception while resolving host: %s", err)
await self.resolver.close()
if response:
sorted_ips = sort_ips(

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/droplet",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["pydroplet==2.3.4"],
"requirements": ["pydroplet==2.3.3"],
"zeroconf": ["_droplet._tcp.local."]
}

View File

@@ -1,42 +0,0 @@
"""The Fing integration."""
from __future__ import annotations
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from .coordinator import FingConfigEntry, FingDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.DEVICE_TRACKER]
async def async_setup_entry(hass: HomeAssistant, config_entry: FingConfigEntry) -> bool:
"""Set up the Fing component."""
coordinator = FingDataUpdateCoordinator(hass, config_entry)
await coordinator.async_config_entry_first_refresh()
if coordinator.data.network_id is None:
_LOGGER.warning(
"Skip setting up Fing integration; Received an empty NetworkId from the request - Check if the API version is the latest"
)
raise ConfigEntryError(
"The Agent's API version is outdated. Please update the agent to the latest version."
)
config_entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, config_entry: FingConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)

View File

@@ -1,114 +0,0 @@
"""Config flow file."""
from contextlib import suppress
import logging
from typing import Any
from fing_agent_api import FingAgent
import httpx
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from .const import DOMAIN, UPNP_AVAILABLE
_LOGGER = logging.getLogger(__name__)
class FingConfigFlow(ConfigFlow, domain=DOMAIN):
"""Fing config flow."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Set up user step."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
devices_response = None
agent_info_response = None
self._async_abort_entries_match(
{CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS]}
)
fing_api = FingAgent(
ip=user_input[CONF_IP_ADDRESS],
port=int(user_input[CONF_PORT]),
key=user_input[CONF_API_KEY],
)
try:
devices_response = await fing_api.get_devices()
with suppress(httpx.ConnectError):
# The suppression is needed because the get_agent_info method isn't available for desktop agents
agent_info_response = await fing_api.get_agent_info()
except httpx.NetworkError as _:
errors["base"] = "cannot_connect"
except httpx.TimeoutException as _:
errors["base"] = "timeout_connect"
except httpx.HTTPStatusError as exception:
description_placeholders["message"] = (
f"{exception.response.status_code} - {exception.response.reason_phrase}"
)
if exception.response.status_code == 401:
errors["base"] = "invalid_api_key"
else:
errors["base"] = "http_status_error"
except httpx.InvalidURL as _:
errors["base"] = "url_error"
except (
httpx.HTTPError,
httpx.CookieConflict,
httpx.StreamError,
) as ex:
_LOGGER.error("Unexpected exception: %s", ex)
errors["base"] = "unknown"
else:
if (
devices_response.network_id is not None
and len(devices_response.network_id) > 0
):
agent_name = user_input.get(CONF_IP_ADDRESS)
upnp_available = False
if agent_info_response is not None:
upnp_available = True
agent_name = agent_info_response.agent_id
await self.async_set_unique_id(agent_info_response.agent_id)
self._abort_if_unique_id_configured()
data = {
CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS],
CONF_PORT: user_input[CONF_PORT],
CONF_API_KEY: user_input[CONF_API_KEY],
UPNP_AVAILABLE: upnp_available,
}
return self.async_create_entry(
title=f"Fing Agent {agent_name}",
data=data,
)
return self.async_abort(reason="api_version_error")
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_IP_ADDRESS): str,
vol.Required(CONF_PORT, default="49090"): str,
vol.Required(CONF_API_KEY): str,
}
),
user_input,
),
errors=errors,
description_placeholders=description_placeholders,
)

View File

@@ -1,4 +0,0 @@
"""Const for the Fing integration."""
DOMAIN = "fing"
UPNP_AVAILABLE = "upnp_available"

View File

@@ -1,85 +0,0 @@
"""DataUpdateCoordinator for Fing integration."""
from dataclasses import dataclass, field
from datetime import timedelta
import logging
from fing_agent_api import FingAgent
from fing_agent_api.models import AgentInfoResponse, Device
import httpx
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPNP_AVAILABLE
_LOGGER = logging.getLogger(__name__)
type FingConfigEntry = ConfigEntry[FingDataUpdateCoordinator]
@dataclass
class FingDataObject:
"""Fing Data Object."""
network_id: str | None = None
agent_info: AgentInfoResponse | None = None
devices: dict[str, Device] = field(default_factory=dict)
class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]):
"""Class to manage fetching data from Fing Agent."""
def __init__(self, hass: HomeAssistant, config_entry: FingConfigEntry) -> None:
"""Initialize global Fing updater."""
self._fing = FingAgent(
ip=config_entry.data[CONF_IP_ADDRESS],
port=int(config_entry.data[CONF_PORT]),
key=config_entry.data[CONF_API_KEY],
)
self._upnp_available = config_entry.data[UPNP_AVAILABLE]
update_interval = timedelta(seconds=30)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=update_interval,
config_entry=config_entry,
)
async def _async_update_data(self) -> FingDataObject:
"""Fetch data from Fing Agent."""
device_response = None
agent_info_response = None
try:
device_response = await self._fing.get_devices()
if self._upnp_available:
agent_info_response = await self._fing.get_agent_info()
except httpx.NetworkError as err:
raise UpdateFailed("Failed to connect") from err
except httpx.TimeoutException as err:
raise UpdateFailed("Timeout establishing connection") from err
except httpx.HTTPStatusError as err:
if err.response.status_code == 401:
raise UpdateFailed("Invalid API key") from err
raise UpdateFailed(
f"Http request failed -> {err.response.status_code} - {err.response.reason_phrase}"
) from err
except httpx.InvalidURL as err:
raise UpdateFailed("Invalid hostname or IP address") from err
except (
httpx.HTTPError,
httpx.CookieConflict,
httpx.StreamError,
) as err:
raise UpdateFailed("Unexpected error from HTTP request") from err
else:
return FingDataObject(
device_response.network_id,
agent_info_response,
{device.mac: device for device in device_response.devices},
)

View File

@@ -1,127 +0,0 @@
"""Platform for Device tracker integration."""
from fing_agent_api.models import Device
from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FingConfigEntry
from .coordinator import FingDataUpdateCoordinator
from .utils import get_icon_from_type
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FingConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add sensors for passed config_entry in HA."""
coordinator = config_entry.runtime_data
entity_registry = er.async_get(hass)
tracked_devices: set[str] = set()
@callback
def add_entities() -> None:
latest_devices = set(coordinator.data.devices.keys())
devices_to_remove = tracked_devices - set(latest_devices)
devices_to_add = set(latest_devices) - tracked_devices
entities_to_remove = []
for entity_entry in entity_registry.entities.values():
if entity_entry.config_entry_id != config_entry.entry_id:
continue
try:
_, mac = entity_entry.unique_id.rsplit("-", 1)
if mac in devices_to_remove:
entities_to_remove.append(entity_entry.entity_id)
except ValueError:
continue
for entity_id in entities_to_remove:
entity_registry.async_remove(entity_id)
entities_to_add = []
for mac_addr in devices_to_add:
device = coordinator.data.devices[mac_addr]
entities_to_add.append(FingTrackedDevice(coordinator, device))
tracked_devices.clear()
tracked_devices.update(latest_devices)
async_add_entities(entities_to_add)
add_entities()
config_entry.async_on_unload(coordinator.async_add_listener(add_entities))
class FingTrackedDevice(CoordinatorEntity[FingDataUpdateCoordinator], ScannerEntity):
"""Represent a tracked device."""
_attr_has_entity_name = True
def __init__(self, coordinator: FingDataUpdateCoordinator, device: Device) -> None:
"""Set up FingDevice entity."""
super().__init__(coordinator)
self._device = device
agent_id = coordinator.data.network_id
if coordinator.data.agent_info is not None:
agent_id = coordinator.data.agent_info.agent_id
self._attr_mac_address = self._device.mac
self._attr_unique_id = f"{agent_id}-{self._attr_mac_address}"
self._attr_name = self._device.name
self._attr_icon = get_icon_from_type(self._device.type)
@property
def is_connected(self) -> bool:
"""Return true if the device is connected to the network."""
return self._device.active
@property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""
return self._device.ip[0] if self._device.ip else None
@property
def entity_registry_enabled_default(self) -> bool:
"""Enable entity by default."""
return True
@property
def unique_id(self) -> str | None:
"""Return the unique ID of the entity."""
return self._attr_unique_id
def check_for_updates(self, new_device: Device) -> bool:
"""Return true if the device has updates."""
new_device_ip = new_device.ip[0] if new_device.ip else None
current_device_ip = self._device.ip[0] if self._device.ip else None
return (
current_device_ip != new_device_ip
or self._device.active != new_device.active
or self._device.type != new_device.type
or self._attr_name != new_device.name
or self._attr_icon != get_icon_from_type(new_device.type)
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
updated_device_data = self.coordinator.data.devices.get(self._device.mac)
if updated_device_data is not None and self.check_for_updates(
updated_device_data
):
self._device = updated_device_data
self._attr_name = updated_device_data.name
self._attr_icon = get_icon_from_type(updated_device_data.type)
er.async_get(self.hass).async_update_entity(
entity_id=self.entity_id,
original_name=self._attr_name,
original_icon=self._attr_icon,
)
self.async_write_ha_state()

View File

@@ -1,10 +0,0 @@
{
"domain": "fing",
"name": "Fing",
"codeowners": ["@Lorenzo-Gasparini"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fing",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["fing_agent_api==1.0.3"]
}

View File

@@ -1,72 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: The integration has no actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: There are no actions in Fing integration.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Fing integration entities do not use events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: The integration has no actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: The integration has no options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: done
entity-category: todo
entity-device-class:
status: exempt
comment: The integration creates only device tracker entities
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: done
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -1,31 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Set up Fing agent",
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]",
"port": "[%key:common::config_flow::data::port%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"ip_address": "IP address of the Fing agent.",
"port": "Port number of the Fing API.",
"api_key": "API key used to authenticate with the Fing API."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"url_error": "[%key:common::config_flow::error::invalid_host%]",
"http_status_error": "HTTP request failed: {message}"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"api_version_error": "Your agent is using an outdated API version. The required 'network_id' parameter is missing. Please update to the latest API version."
}
}
}

View File

@@ -1,85 +0,0 @@
"""Utils functions."""
from enum import Enum
class DeviceType(Enum):
"""Device types enum."""
GENERIC = "mdi:lan-connect"
MOBILE = PHONE = "mdi:cellphone"
TABLET = IPOD = EREADER = "mdi:tablet"
WATCH = WEARABLE = "mdi:watch"
CAR = AUTOMOTIVE = "mdi:car-back"
MEDIA_PLAYER = "mdi:volume-high"
TELEVISION = "mdi:television"
GAME_CONSOLE = "mdi:nintendo-game-boy"
STREAMING_DONGLE = "mdi:cast"
LOUDSPEAKER = SOUND_SYSTEM = STB = SATELLITE = MUSIC = "mdi:speaker"
DISC_PLAYER = "mdi:disk-player"
REMOTE_CONTROL = "mdi:remote-tv"
RADIO = "mdi:radio"
PHOTO_CAMERA = PHOTOS = "mdi:camera"
MICROPHONE = VOICE_CONTROL = "mdi:microphone"
PROJECTOR = "mdi:projector"
COMPUTER = DESKTOP = "mdi:desktop-tower"
LAPTOP = "mdi:laptop"
PRINTER = "mdi:printer"
SCANNER = "mdi:scanner"
POS = "mdi:printer-pos"
CLOCK = "mdi:clock"
BARCODE = "mdi:barcode"
SURVEILLANCE_CAMERA = BABY_MONITOR = PET_MONITOR = "mdi:cctv"
POE_PLUG = HEALTH_MONITOR = SMART_HOME = SMART_METER = APPLIANCE = SLEEP = (
"mdi:home-automation"
)
SMART_PLUG = "mdi:power-plug"
LIGHT = "mdi:lightbulb"
THERMOSTAT = HEATING = "mdi:home-thermometer"
POWER_SYSTEM = ENERGY = "mdi:lightning-bolt"
SOLAR_PANEL = "mdi:solar-power"
WASHER = "mdi:washing-machine"
FRIDGE = "mdi:fridge"
CLEANER = "mdi:vacuum"
GARAGE = "mdi:garage"
SPRINKLER = "mdi:sprinkler"
BELL = "mdi:doorbell"
KEY_LOCK = "mdi:lock-smart"
CONTROL_PANEL = SMART_CONTROLLER = "mdi:alarm-panel"
SCALE = "mdi:scale-bathroom"
TOY = "mdi:teddy-bear"
ROBOT = "mdi:robot"
WEATHER = "mdi:weather-cloudy"
ALARM = "mdi:alarm-light"
MOTION_DETECTOR = "mdi:motion-sensor"
SMOKE = HUMIDITY = SENSOR = DOMOTZ_BOX = FINGBOX = "mdi:smoke-detector"
ROUTER = MODEM = GATEWAY = FIREWALL = VPN = SMALL_CELL = "mdi:router-network"
WIFI = WIFI_EXTENDER = "mdi:wifi"
NAS_STORAGE = "mdi:nas"
SWITCH = "mdi:switch"
USB = "mdi:usb"
CLOUD = "mdi:cloud"
BATTERY = "mdi:battery"
NETWORK_APPLIANCE = "mdi:network"
VIRTUAL_MACHINE = MAIL_SERVER = FILE_SERVER = PROXY_SERVER = WEB_SERVER = (
DOMAIN_SERVER
) = COMMUNICATION = "mdi:monitor"
SERVER = "mdi:server"
TERMINAL = "mdi:console"
DATABASE = "mdi:database"
RASPBERRY = ARDUINO = "mdi:raspberry-pi"
PROCESSOR = CIRCUIT_CARD = RFID = "mdi:chip"
INDUSTRIAL = "mdi:factory"
MEDICAL = "mdi:medical-bag"
VOIP = CONFERENCING = "mdi:phone-voip"
FITNESS = "mdi:dumbbell"
POOL = "mdi:pool"
SECURITY_SYSTEM = "mdi:security"
def get_icon_from_type(type: str) -> str:
"""Return the right icon based on the type."""
try:
return DeviceType[type].value
except (ValueError, KeyError):
return "mdi:lan-connect"

View File

@@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
RECOMMENDED_VERSION = "1.9.11"
RECOMMENDED_VERSION = "1.9.9"

View File

@@ -186,7 +186,6 @@ async def async_setup_entry(
class InverterSensor(CoordinatorEntity[GoodweUpdateCoordinator], SensorEntity):
"""Entity representing individual inverter sensor."""
_attr_has_entity_name = True
entity_description: GoodweSensorEntityDescription
def __init__(

View File

@@ -15,13 +15,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from .const import (
CONF_CHAT_MODEL,
CONF_RECOMMENDED,
LOGGER,
RECOMMENDED_A_TASK_MAX_TOKENS,
RECOMMENDED_IMAGE_MODEL,
)
from .const import CONF_CHAT_MODEL, CONF_RECOMMENDED, LOGGER, RECOMMENDED_IMAGE_MODEL
from .entity import (
ERROR_GETTING_RESPONSE,
GoogleGenerativeAILLMBaseEntity,
@@ -79,9 +73,7 @@ class GoogleGenerativeAITaskEntity(
chat_log: conversation.ChatLog,
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(
chat_log, task.structure, default_max_tokens=RECOMMENDED_A_TASK_MAX_TOKENS
)
await self._async_handle_chat_log(chat_log, task.structure)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
LOGGER.error(

View File

@@ -32,8 +32,6 @@ CONF_TOP_K = "top_k"
RECOMMENDED_TOP_K = 64
CONF_MAX_TOKENS = "max_tokens"
RECOMMENDED_MAX_TOKENS = 3000
# Input 5000, output 19400 = 0.05 USD
RECOMMENDED_A_TASK_MAX_TOKENS = 19400
CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold"
CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"

View File

@@ -472,7 +472,6 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
self,
chat_log: conversation.ChatLog,
structure: vol.Schema | None = None,
default_max_tokens: int | None = None,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
@@ -619,9 +618,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
if not chat_log.unresponded_tool_results:
break
def create_generate_content_config(
self, default_max_tokens: int | None = None
) -> GenerateContentConfig:
def create_generate_content_config(self) -> GenerateContentConfig:
"""Create the GenerateContentConfig for the LLM."""
options = self.subentry.data
model = options.get(CONF_CHAT_MODEL, self.default_model)
@@ -635,12 +632,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=options.get(
CONF_MAX_TOKENS,
default_max_tokens
if default_max_tokens is not None
else RECOMMENDED_MAX_TOKENS,
),
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,

View File

@@ -59,7 +59,6 @@ class GoogleGenerativeAITextToSpeechEntity(
"en-US",
"es-US",
"fr-FR",
"he-IL",
"hi-IN",
"id-ID",
"it-IT",

View File

@@ -72,7 +72,6 @@ PLATFORMS = [
Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
Platform.VALVE,
]
_LOGGER = logging.getLogger(__name__)

View File

@@ -35,7 +35,6 @@ from .media_player import MediaPlayerGroup, async_create_preview_media_player
from .notify import async_create_preview_notify
from .sensor import async_create_preview_sensor
from .switch import async_create_preview_switch
from .valve import async_create_preview_valve
_STATISTIC_MEASURES = [
"last",
@@ -173,7 +172,6 @@ GROUP_TYPES = [
"notify",
"sensor",
"switch",
"valve",
]
@@ -255,11 +253,6 @@ CONFIG_FLOW = {
preview="group",
validate_user_input=set_group_type("switch"),
),
"valve": SchemaFlowFormStep(
basic_group_config_schema("valve"),
preview="group",
validate_user_input=set_group_type("valve"),
),
}
@@ -309,10 +302,6 @@ OPTIONS_FLOW = {
partial(light_switch_options_schema, "switch"),
preview="group",
),
"valve": SchemaFlowFormStep(
partial(basic_group_options_schema, "valve"),
preview="group",
),
}
PREVIEW_OPTIONS_SCHEMA: dict[str, vol.Schema] = {}
@@ -332,7 +321,6 @@ CREATE_PREVIEW_ENTITY: dict[
"notify": async_create_preview_notify,
"sensor": async_create_preview_sensor,
"switch": async_create_preview_switch,
"valve": async_create_preview_valve,
}

View File

@@ -16,8 +16,7 @@
"media_player": "Media player group",
"notify": "Notify group",
"sensor": "Sensor group",
"switch": "Switch group",
"valve": "Valve group"
"switch": "Switch group"
}
},
"binary_sensor": {
@@ -128,18 +127,6 @@
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
},
"valve": {
"title": "[%key:component::group::config::step::user::title%]",
"data": {
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
}
}
},
@@ -225,16 +212,6 @@
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
},
"valve": {
"data": {
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
},
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
}
}
},

View File

@@ -1,262 +0,0 @@
"""Platform allowing several valves to be grouped into one valve."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.valve import (
ATTR_CURRENT_POSITION,
ATTR_POSITION,
DOMAIN as VALVE_DOMAIN,
PLATFORM_SCHEMA as VALVE_PLATFORM_SCHEMA,
ValveEntity,
ValveEntityFeature,
ValveState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
CONF_ENTITIES,
CONF_NAME,
CONF_UNIQUE_ID,
SERVICE_CLOSE_VALVE,
SERVICE_OPEN_VALVE,
SERVICE_SET_VALVE_POSITION,
SERVICE_STOP_VALVE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
from .util import reduce_attribute
KEY_OPEN_CLOSE = "open_close"
KEY_STOP = "stop"
KEY_SET_POSITION = "set_position"
DEFAULT_NAME = "Valve Group"
# No limit on parallel updates to enable a group calling another group
PARALLEL_UPDATES = 0
PLATFORM_SCHEMA = VALVE_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_ENTITIES): cv.entities_domain(VALVE_DOMAIN),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Valve Group platform."""
async_add_entities(
[
ValveGroup(
config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES]
)
]
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Valve Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
async_add_entities(
[ValveGroup(config_entry.entry_id, config_entry.title, entities)]
)
@callback
def async_create_preview_valve(
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
) -> ValveGroup:
"""Create a preview valve."""
return ValveGroup(
None,
name,
validated_config[CONF_ENTITIES],
)
class ValveGroup(GroupEntity, ValveEntity):
"""Representation of a ValveGroup."""
_attr_available: bool = False
_attr_current_valve_position: int | None = None
_attr_is_closed: bool | None = None
_attr_is_closing: bool | None = False
_attr_is_opening: bool | None = False
_attr_reports_position: bool = False
def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None:
"""Initialize a ValveGroup entity."""
self._entity_ids = entities
self._valves: dict[str, set[str]] = {
KEY_OPEN_CLOSE: set(),
KEY_STOP: set(),
KEY_SET_POSITION: set(),
}
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities}
self._attr_unique_id = unique_id
@callback
def async_update_supported_features(
self,
entity_id: str,
new_state: State | None,
) -> None:
"""Update dictionaries with supported features."""
if not new_state:
for values in self._valves.values():
values.discard(entity_id)
return
features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if features & (ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE):
self._valves[KEY_OPEN_CLOSE].add(entity_id)
else:
self._valves[KEY_OPEN_CLOSE].discard(entity_id)
if features & (ValveEntityFeature.STOP):
self._valves[KEY_STOP].add(entity_id)
else:
self._valves[KEY_STOP].discard(entity_id)
if features & (ValveEntityFeature.SET_POSITION):
self._valves[KEY_SET_POSITION].add(entity_id)
else:
self._valves[KEY_SET_POSITION].discard(entity_id)
async def async_open_valve(self) -> None:
"""Open the valves."""
data = {ATTR_ENTITY_ID: self._valves[KEY_OPEN_CLOSE]}
await self.hass.services.async_call(
VALVE_DOMAIN, SERVICE_OPEN_VALVE, data, blocking=True, context=self._context
)
async def async_handle_open_valve(self) -> None: # type: ignore[misc]
"""Open the valves.
Override the base class to avoid calling the set position service
for all valves. Transfer the service call to the base class and let
it decide if the valve uses set position or open service.
"""
await self.async_open_valve()
async def async_close_valve(self) -> None:
"""Close valves."""
data = {ATTR_ENTITY_ID: self._valves[KEY_OPEN_CLOSE]}
await self.hass.services.async_call(
VALVE_DOMAIN,
SERVICE_CLOSE_VALVE,
data,
blocking=True,
context=self._context,
)
async def async_handle_close_valve(self) -> None: # type: ignore[misc]
"""Close the valves.
Override the base class to avoid calling the set position service
for all valves. Transfer the service call to the base class and let
it decide if the valve uses set position or close service.
"""
await self.async_close_valve()
async def async_set_valve_position(self, position: int) -> None:
"""Move the valves to a specific position."""
data = {
ATTR_ENTITY_ID: self._valves[KEY_SET_POSITION],
ATTR_POSITION: position,
}
await self.hass.services.async_call(
VALVE_DOMAIN,
SERVICE_SET_VALVE_POSITION,
data,
blocking=True,
context=self._context,
)
async def async_stop_valve(self) -> None:
"""Stop the valves."""
data = {ATTR_ENTITY_ID: self._valves[KEY_STOP]}
await self.hass.services.async_call(
VALVE_DOMAIN, SERVICE_STOP_VALVE, data, blocking=True, context=self._context
)
@callback
def async_update_group_state(self) -> None:
"""Update state and attributes."""
states = [
state
for entity_id in self._entity_ids
if (state := self.hass.states.get(entity_id)) is not None
]
# Set group as unavailable if all members are unavailable or missing
self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states)
self._attr_is_closed = True
self._attr_is_closing = False
self._attr_is_opening = False
self._attr_reports_position = False
self._update_assumed_state_from_members()
for state in states:
if state.attributes.get(ATTR_CURRENT_POSITION) is not None:
self._attr_reports_position = True
if state.state == ValveState.OPEN:
self._attr_is_closed = False
continue
if state.state == ValveState.CLOSED:
continue
if state.state == ValveState.CLOSING:
self._attr_is_closing = True
continue
if state.state == ValveState.OPENING:
self._attr_is_opening = True
continue
valid_state = any(
state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states
)
if not valid_state:
# Set as unknown if all members are unknown or unavailable
self._attr_is_closed = None
self._attr_current_valve_position = reduce_attribute(
states, ATTR_CURRENT_POSITION
)
supported_features = ValveEntityFeature(0)
if self._valves[KEY_OPEN_CLOSE]:
supported_features |= ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
if self._valves[KEY_STOP]:
supported_features |= ValveEntityFeature.STOP
if self._valves[KEY_SET_POSITION]:
supported_features |= ValveEntityFeature.SET_POSITION
self._attr_supported_features = supported_features

View File

@@ -3,15 +3,18 @@
from __future__ import annotations
import logging
import sys
from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS
from .data import HarmonyConfigEntry, HarmonyData
if sys.version_info < (3, 14):
from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS
from .data import HarmonyConfigEntry, HarmonyData
_LOGGER = logging.getLogger(__name__)
@@ -22,6 +25,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HarmonyConfigEntry) -> b
# when setting up a config entry, we fallback to adding
# the options to the config entry and pull them out here if
# they are missing from the options
if sys.version_info >= (3, 14):
raise HomeAssistantError(
"Logitech Harmony Hub is not supported on Python 3.14. Please use Python 3.13."
)
_async_import_options_from_data_if_missing(hass, entry)
address = entry.data[CONF_HOST]

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/harmony",
"iot_class": "local_push",
"loggers": ["aioharmony", "slixmpp"],
"requirements": ["aioharmony==0.5.3"],
"requirements": ["aioharmony==0.5.3;python_version<'3.14'"],
"ssdp": [
{
"manufacturer": "Logitech",

View File

@@ -109,8 +109,6 @@ DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
PLACEHOLDER_KEY_ADDON = "addon"
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
PLACEHOLDER_KEY_ADDON_DOCUMENTATION = "addon_documentation"
PLACEHOLDER_KEY_ADDON_URL = "addon_url"
PLACEHOLDER_KEY_REFERENCE = "reference"
PLACEHOLDER_KEY_COMPONENTS = "components"
@@ -122,7 +120,6 @@ ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing"
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"
CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor"
@@ -159,7 +156,6 @@ EXTRA_PLACEHOLDERS = {
ISSUE_KEY_ADDON_PWNED: {
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
},
ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS,
}

View File

@@ -43,7 +43,6 @@ from .const import (
EVENT_SUPPORTED_CHANGED,
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
@@ -85,7 +84,6 @@ ISSUE_KEYS_FOR_REPAIRS = {
"issue_system_disk_lifetime",
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED,
}
_LOGGER = logging.getLogger(__name__)

View File

@@ -18,13 +18,10 @@ from . import get_addons_info, get_issues_info
from .const import (
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_ADDON,
PLACEHOLDER_KEY_ADDON_DOCUMENTATION,
PLACEHOLDER_KEY_ADDON_INFO,
PLACEHOLDER_KEY_COMPONENTS,
PLACEHOLDER_KEY_REFERENCE,
)
@@ -198,23 +195,6 @@ class AddonIssueRepairFlow(SupervisorIssueRepairFlow):
return placeholders or None
class DeprecatedAddonIssueRepairFlow(AddonIssueRepairFlow):
"""Handler for deprecated addon issue fixing flows."""
@property
def description_placeholders(self) -> dict[str, str] | None:
"""Get description placeholders for steps."""
placeholders: dict[str, str] = super().description_placeholders or {}
if self.issue and self.issue.reference:
placeholders[PLACEHOLDER_KEY_ADDON_INFO] = (
f"homeassistant://hassio/addon/{self.issue.reference}/info"
)
placeholders[PLACEHOLDER_KEY_ADDON_DOCUMENTATION] = (
f"homeassistant://hassio/addon/{self.issue.reference}/documentation"
)
return placeholders or None
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
@@ -225,8 +205,6 @@ async def async_create_fix_flow(
issue = supervisor_issues and supervisor_issues.get_issue(issue_id)
if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG:
return DockerConfigIssueRepairFlow(hass, issue_id)
if issue and issue.key == ISSUE_KEY_ADDON_DEPRECATED:
return DeprecatedAddonIssueRepairFlow(hass, issue_id)
if issue and issue.key in {
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_BOOT_FAIL,

View File

@@ -56,19 +56,6 @@
"title": "Insecure secrets detected in add-on configuration",
"description": "Add-on {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue."
},
"issue_addon_deprecated_addon": {
"title": "Installed add-on is deprecated",
"fix_flow": {
"step": {
"addon_execute_remove": {
"description": "Add-on {addon} is marked deprecated by the developer. This means it is no longer being maintained and so may break or become a security issue over time.\n\nReview the [readme]({addon_info}) and [documentation]({addon_documentation}) of the add-on to see if the developer provided instructions.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
}
},
"abort": {
"apply_suggestion_fail": "Could not uninstall the add-on. Check the Supervisor logs for more details."
}
}
},
"issue_mount_mount_failed": {
"title": "Network storage device failed",
"fix_flow": {

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.83", "babel==2.15.0"]
"requirements": ["holidays==0.82", "babel==2.15.0"]
}

View File

@@ -94,14 +94,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
for device in devices:
# Check if the device is still present in homee
device_identifiers = {identifier[1] for identifier in device.identifiers}
# homee itself uses just the uid, nodes use {uid}-{nodeid}
if homee.settings.uid in device_identifiers:
continue # Hub itself is never removed.
# homee itself uses just the uid, nodes use uid-nodeid
is_homee_hub = homee.settings.uid in device_identifiers
is_node_present = any(
f"{homee.settings.uid}-{node.id}" in device_identifiers
for node in homee.nodes
)
if not is_node_present:
if not is_node_present and not is_homee_hub:
_LOGGER.info("Removing device %s", device.name)
device_registry.async_update_device(
device_id=device.id,
@@ -111,17 +110,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
# Remove device at runtime when node is removed in homee
async def _remove_node_callback(node: HomeeNode, add: bool) -> None:
"""Call when a node is removed."""
if add:
return
device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{entry.runtime_data.settings.uid}-{node.id}")}
)
if device:
_LOGGER.info("Removing device %s", device.name)
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=entry.entry_id,
if not add:
device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{entry.runtime_data.settings.uid}-{node.id}")}
)
if device:
_LOGGER.info("Removing device %s", device.name)
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=entry.entry_id,
)
homee.add_nodes_listener(_remove_node_callback)

View File

@@ -43,22 +43,18 @@ def async_setup_forwarded(
some proxies, for example, Kubernetes NGINX ingress, only retain one element
in the X-Forwarded-Proto header. In that case, we'll just use what we have.
`X-Forwarded-Host: <host1>, <host2>, <host3>`
e.g., `X-Forwarded-Host: example.com, proxy.example.com, backend.example.com`
OR `X-Forwarded-Host: example.com` (one entry, even with multiple proxies)
`X-Forwarded-Host: <host>`
e.g., `X-Forwarded-Host: example.com`
If the previous headers are processed successfully, and the X-Forwarded-Host is
present, the last one in the list will be used (set by the proxy nearest to the backend).
Multiple headers are valid as stated in https://www.rfc-editor.org/rfc/rfc7239#section-7.1
If multiple headers are present, they are handled according to
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For#parsing
> "split each X-Forwarded-For header by comma into lists and then join the lists."
present, it will be used.
Additionally:
- If no X-Forwarded-For header is found, the processing of all headers is skipped.
- Throw HTTP 400 status when untrusted connected peer provides
X-Forwarded-For headers.
- If multiple instances of X-Forwarded-For, X-Forwarded-Proto or
X-Forwarded-Host are found, an HTTP 400 status code is thrown.
- If malformed or invalid (IP) data in X-Forwarded-For header is found,
an HTTP 400 status code is thrown.
- The connected client peer on the socket of the incoming connection,
@@ -115,12 +111,15 @@ def async_setup_forwarded(
)
raise HTTPBadRequest
# Process multiple X-Forwarded-For from the right side (by reversing the list)
forwarded_for_split = list(
reversed(
[addr for header in forwarded_for_headers for addr in header.split(",")]
# Multiple X-Forwarded-For headers
if len(forwarded_for_headers) > 1:
_LOGGER.error(
"Too many headers for X-Forwarded-For: %s", forwarded_for_headers
)
)
raise HTTPBadRequest
# Process X-Forwarded-For from the right side (by reversing the list)
forwarded_for_split = list(reversed(forwarded_for_headers[0].split(",")))
try:
forwarded_for = [ip_address(addr.strip()) for addr in forwarded_for_split]
except ValueError as err:
@@ -149,15 +148,14 @@ def async_setup_forwarded(
X_FORWARDED_PROTO, []
)
if forwarded_proto_headers:
# Process multiple X-Forwarded-Proto from the right side (by reversing the list)
forwarded_proto_split = list(
reversed(
[
addr
for header in forwarded_proto_headers
for addr in header.split(",")
]
if len(forwarded_proto_headers) > 1:
_LOGGER.error(
"Too many headers for X-Forward-Proto: %s", forwarded_proto_headers
)
raise HTTPBadRequest
forwarded_proto_split = list(
reversed(forwarded_proto_headers[0].split(","))
)
forwarded_proto = [proto.strip() for proto in forwarded_proto_split]
@@ -193,16 +191,14 @@ def async_setup_forwarded(
# Handle X-Forwarded-Host
forwarded_host_headers: list[str] = request.headers.getall(X_FORWARDED_HOST, [])
if forwarded_host_headers:
# Process multiple X-Forwarded-Host from the right side (by reversing the list)
forwarded_host = list(
reversed(
[
addr.strip()
for header in forwarded_host_headers
for addr in header.split(",")
]
# Multiple X-Forwarded-Host headers
if len(forwarded_host_headers) > 1:
_LOGGER.error(
"Too many headers for X-Forwarded-Host: %s", forwarded_host_headers
)
)[0]
raise HTTPBadRequest
forwarded_host = forwarded_host_headers[0].strip()
if not forwarded_host:
_LOGGER.error("Empty value received in X-Forward-Host header")
raise HTTPBadRequest

View File

@@ -41,7 +41,7 @@ rules:
reauthentication-flow: done
test-coverage:
status: todo
comment: Get percentage up there, add missing actual action press invocations in button tests' suspended state tests.
comment: Get percentage up there, add missing actual action press invocations in button tests' suspended state tests, rename test_switch.py to test_switch.py + make its functions receive hass as first parameter where applicable.
# Gold
devices: done

View File

@@ -112,7 +112,7 @@ class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity):
self.mower_attributes
)
@handle_sending_exception
@handle_sending_exception()
async def async_press(self) -> None:
"""Send a command to the mower."""
await self.entity_description.press_fn(self.coordinator.api, self.mower_id)

View File

@@ -182,6 +182,14 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
"Failed to listen to websocket. Trying to reconnect: %s",
err,
)
if not hass.is_stopping:
await asyncio.sleep(self.reconnect_time)
self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME)
entry.async_create_background_task(
hass,
self.client_listen(hass, entry, automower_client),
"reconnect_task",
)
def _should_poll(self) -> bool:
"""Return True if at least one mower is connected and at least one is not OFF."""

View File

@@ -6,7 +6,7 @@ import asyncio
from collections.abc import Callable, Coroutine
import functools
import logging
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, overload
from typing import TYPE_CHECKING, Any, Concatenate
from aioautomower.exceptions import ApiError
from aioautomower.model import MowerActivities, MowerAttributes, MowerStates, WorkArea
@@ -37,42 +37,23 @@ ERROR_STATES = [
]
_Entity = TypeVar("_Entity", bound="AutomowerBaseEntity")
_P = ParamSpec("_P")
@callback
def _work_area_translation_key(work_area_id: int, key: str) -> str:
"""Return the translation key."""
if work_area_id == 0:
return f"my_lawn_{key}"
return f"work_area_{key}"
@overload
def handle_sending_exception(
_func: Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]]: ...
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]
@overload
def handle_sending_exception(
*,
def handle_sending_exception[_Entity: AutomowerBaseEntity, **_P](
poll_after_sending: bool = False,
) -> Callable[
[Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]]],
Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]],
]: ...
def handle_sending_exception(
_func: Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]] | None = None,
*,
poll_after_sending: bool = False,
) -> (
Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]]
| Callable[
[Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]]],
Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]],
]
):
) -> Callable[[_FuncType[_Entity, _P, Any]], _FuncType[_Entity, _P, None]]:
"""Handle exceptions while sending a command and optionally refresh coordinator."""
def decorator(
func: Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]]:
def decorator(func: _FuncType[_Entity, _P, Any]) -> _FuncType[_Entity, _P, None]:
@functools.wraps(func)
async def wrapper(self: _Entity, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
@@ -92,20 +73,7 @@ def handle_sending_exception(
return wrapper
if _func is None:
# call with brackets: @handle_sending_exception(...)
return decorator
# call without brackets: @handle_sending_exception
return decorator(_func)
@callback
def _work_area_translation_key(work_area_id: int, key: str) -> str:
"""Return the translation key."""
if work_area_id == 0:
return f"my_lawn_{key}"
return f"work_area_{key}"
return decorator
class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):

View File

@@ -135,22 +135,22 @@ class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity):
"""Return the work areas of the mower."""
return self.mower_attributes.work_areas
@handle_sending_exception
@handle_sending_exception()
async def async_start_mowing(self) -> None:
"""Resume schedule."""
await self.coordinator.api.commands.resume_schedule(self.mower_id)
@handle_sending_exception
@handle_sending_exception()
async def async_pause(self) -> None:
"""Pauses the mower."""
await self.coordinator.api.commands.pause_mowing(self.mower_id)
@handle_sending_exception
@handle_sending_exception()
async def async_dock(self) -> None:
"""Parks the mower until next schedule."""
await self.coordinator.api.commands.park_until_next_schedule(self.mower_id)
@handle_sending_exception
@handle_sending_exception()
async def async_override_schedule(
self, override_mode: str, duration: timedelta
) -> None:
@@ -160,7 +160,7 @@ class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity):
if override_mode == PARK:
await self.coordinator.api.commands.park_for(self.mower_id, duration)
@handle_sending_exception
@handle_sending_exception()
async def async_override_schedule_work_area(
self, work_area_id: int, duration: timedelta
) -> None:

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.5.0"]
"requirements": ["aioautomower==2.2.1"]
}

View File

@@ -67,7 +67,7 @@ class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity):
"""Return the current option for the entity."""
return cast(HeadlightModes, self.mower_attributes.settings.headlight.mode)
@handle_sending_exception
@handle_sending_exception()
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.coordinator.api.commands.set_headlight_mode(

View File

@@ -108,12 +108,12 @@ class AutomowerScheduleSwitchEntity(AutomowerControlEntity, SwitchEntity):
"""Return the state of the switch."""
return self.mower_attributes.mower.mode != MowerModes.HOME
@handle_sending_exception
@handle_sending_exception()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.coordinator.api.commands.park_until_further_notice(self.mower_id)
@handle_sending_exception
@handle_sending_exception()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.coordinator.api.commands.resume_schedule(self.mower_id)

View File

@@ -18,7 +18,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
from .entity import HuumBaseEntity
@@ -56,12 +55,12 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
@property
def min_temp(self) -> int:
"""Return configured minimal temperature."""
return self.coordinator.data.sauna_config.min_temp or CONFIG_DEFAULT_MIN_TEMP
return self.coordinator.data.sauna_config.min_temp
@property
def max_temp(self) -> int:
"""Return configured maximum temperature."""
return self.coordinator.data.sauna_config.max_temp or CONFIG_DEFAULT_MAX_TEMP
return self.coordinator.data.sauna_config.max_temp
@property
def hvac_mode(self) -> HVACMode:

View File

@@ -9,6 +9,3 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.
CONFIG_STEAMER = 1
CONFIG_LIGHT = 2
CONFIG_STEAMER_AND_LIGHT = 3
CONFIG_DEFAULT_MIN_TEMP = 40
CONFIG_DEFAULT_MAX_TEMP = 110

View File

@@ -1,95 +0,0 @@
"""The iNELS integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from inelsmqtt import InelsMqtt
from inelsmqtt.devices import Device
from inelsmqtt.discovery import InelsDiscovery
from homeassistant.components import mqtt as ha_mqtt
from homeassistant.components.mqtt import (
ReceiveMessage,
async_prepare_subscribe_topics,
async_subscribe_topics,
async_unsubscribe_topics,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from .const import LOGGER, PLATFORMS
type InelsConfigEntry = ConfigEntry[InelsData]
@dataclass
class InelsData:
"""Represents the data structure for INELS runtime data."""
mqtt: InelsMqtt
devices: list[Device]
async def async_setup_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool:
"""Set up iNELS from a config entry."""
async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None:
"""Publish an MQTT message using the Home Assistant MQTT client."""
await ha_mqtt.async_publish(hass, topic, payload, qos, retain)
async def mqtt_subscribe(
sub_state: dict[str, Any] | None,
topic: str,
callback_func: Callable[[str, str], None],
) -> dict[str, Any]:
"""Subscribe to MQTT topics using the Home Assistant MQTT client."""
@callback
def mqtt_message_received(msg: ReceiveMessage) -> None:
"""Handle iNELS mqtt messages."""
# Payload is always str at runtime since we don't set encoding=None
# HA uses UTF-8 by default
callback_func(msg.topic, msg.payload) # type: ignore[arg-type]
topics = {
"inels_subscribe_topic": {
"topic": topic,
"msg_callback": mqtt_message_received,
}
}
sub_state = async_prepare_subscribe_topics(hass, sub_state, topics)
await async_subscribe_topics(hass, sub_state)
return sub_state
async def mqtt_unsubscribe(sub_state: dict[str, Any]) -> None:
async_unsubscribe_topics(hass, sub_state)
if not await ha_mqtt.async_wait_for_mqtt_client(hass):
LOGGER.error("MQTT integration not available")
raise ConfigEntryNotReady("MQTT integration not available")
inels_mqtt = InelsMqtt(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe)
devices: list[Device] = await InelsDiscovery(inels_mqtt).start()
# If no devices are discovered, continue with the setup
if not devices:
LOGGER.info("No devices discovered")
entry.runtime_data = InelsData(mqtt=inels_mqtt, devices=devices)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.mqtt.unsubscribe_topics()
entry.runtime_data.mqtt.unsubscribe_listeners()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,73 +0,0 @@
"""Config flow for iNELS."""
from __future__ import annotations
from typing import Any
from homeassistant.components import mqtt
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from .const import DOMAIN, TITLE
class INelsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle of iNELS config flow."""
VERSION = 1
async def async_step_mqtt(
self, discovery_info: MqttServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by MQTT discovery."""
if self._async_in_progress():
return self.async_abort(reason="already_in_progress")
# Validate the message, abort if it fails.
if not discovery_info.topic.endswith("/gw"):
# Not an iNELS discovery message.
return self.async_abort(reason="invalid_discovery_info")
if not discovery_info.payload:
# Empty payload, unexpected payload.
return self.async_abort(reason="invalid_discovery_info")
return await self.async_step_confirm_from_mqtt()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
try:
if not mqtt.is_connected(self.hass):
return self.async_abort(reason="mqtt_not_connected")
except KeyError:
return self.async_abort(reason="mqtt_not_configured")
return await self.async_step_confirm_from_user()
async def step_confirm(
self, step_id: str, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup."""
if user_input is not None:
await self.async_set_unique_id(DOMAIN)
return self.async_create_entry(title=TITLE, data={})
return self.async_show_form(step_id=step_id)
async def async_step_confirm_from_mqtt(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup from MQTT discovered."""
return await self.step_confirm(
step_id="confirm_from_mqtt", user_input=user_input
)
async def async_step_confirm_from_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup from user add integration."""
return await self.step_confirm(
step_id="confirm_from_user", user_input=user_input
)

View File

@@ -1,14 +0,0 @@
"""Constants for the iNELS integration."""
import logging
from homeassistant.const import Platform
DOMAIN = "inels"
TITLE = "iNELS"
PLATFORMS: list[Platform] = [
Platform.SWITCH,
]
LOGGER = logging.getLogger(__package__)

View File

@@ -1,61 +0,0 @@
"""Base class for iNELS components."""
from __future__ import annotations
from inelsmqtt.devices import Device
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
class InelsBaseEntity(Entity):
"""Base iNELS entity."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(
self,
device: Device,
key: str,
index: int,
) -> None:
"""Init base entity."""
self._device = device
self._device_id = device.unique_id
self._attr_unique_id = self._device_id
# The referenced variable to read from
self._key = key
# The index of the variable list to read from. '-1' for no index
self._index = index
info = device.info()
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
manufacturer=info.manufacturer,
model=info.model_number,
name=device.title,
sw_version=info.sw_version,
)
async def async_added_to_hass(self) -> None:
"""Add subscription of the data listener."""
# Register the HA callback
self._device.add_ha_callback(self._key, self._index, self._callback)
# Subscribe to MQTT updates
self._device.mqtt.subscribe_listener(
self._device.state_topic, self._device.unique_id, self._device.callback
)
def _callback(self) -> None:
"""Get data from broker into the HA."""
if hasattr(self, "hass"):
self.schedule_update_ha_state()
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._device.is_available

View File

@@ -1,15 +0,0 @@
{
"entity": {
"switch": {
"bit": {
"default": "mdi:power-socket-eu"
},
"simple_relay": {
"default": "mdi:power-socket-eu"
},
"relay": {
"default": "mdi:power-socket-eu"
}
}
}
}

View File

@@ -1,13 +0,0 @@
{
"domain": "inels",
"name": "iNELS",
"codeowners": ["@epdevlab"],
"config_flow": true,
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/inels",
"iot_class": "local_push",
"mqtt": ["inels/status/#"],
"quality_scale": "bronze",
"requirements": ["elkoep-aio-mqtt==0.1.0b4"],
"single_config_entry": true
}

View File

@@ -1,118 +0,0 @@
rules:
# Bronze
config-flow: done
test-before-configure: done
unique-config-entry: done
config-flow-test-coverage: done
runtime-data: done
test-before-setup:
status: done
comment: >
Raise "Invalid authentication" and "MQTT Broker is offline or
cannot be reached" otherwise, async_setup_entry returns False
appropriate-polling:
status: done
comment: |
Integration uses local_push.
entity-unique-id:
status: done
comment: |
{MAC}_{DEVICE_ID} is used, for example, 0e97f8b7d30_02E8.
has-entity-name:
status: done
comment: >
Almost all devices are multi-functional, which means that all functions
are equally important -> keep the descriptive name (not setting _attr_name to None).
entity-event-setup:
status: done
comment: |
Subscribe in async_added_to_hass & unsubscribe from async_unload_entry.
dependency-transparency: done
action-setup:
status: exempt
comment: |
No custom actions are defined.
common-modules: done
docs-high-level-description: done
docs-installation-instructions:
status: done
comment: |
A link to the wiki is provided.
docs-removal-instructions: done
docs-actions:
status: exempt
comment: |
No custom actions are defined.
brands: done
# Silver
config-entry-unloading: done
log-when-unavailable: todo
entity-unavailable:
status: done
comment: |
available property.
action-exceptions:
status: exempt
comment: |
No custom actions are defined.
reauthentication-flow: todo
parallel-updates:
status: todo
comment: |
For all platforms, add a constant PARALLEL_UPDATES = 0.
test-coverage: done
integration-owner: done
docs-installation-parameters:
status: done
comment: |
A link to the wiki is provided.
docs-configuration-parameters:
status: exempt
comment: >
There is the same options flow in the integration as there is in the
configuration.
# Gold
entity-translations: done
entity-device-class: todo
devices: done
entity-category: todo
entity-disabled-by-default: todo
discovery:
status: todo
comment: |
Currently blocked by a hw limitation.
stale-devices:
status: todo
comment: >
Same as discovery. The async_remove_config_entry_device function should be
implemented at a minimum.
diagnostics: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
dynamic-devices: todo
discovery-update-info:
status: todo
comment: |
Same as discovery.
repair-issues: todo
docs-use-cases: todo
docs-supported-devices:
status: todo
comment: >
In regards to this and below doc requirements, I am not sure whether the
wiki link is acceptable.
docs-supported-functions: todo
docs-data-update: todo
docs-known-limitations: todo
docs-troubleshooting: todo
docs-examples: todo
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
The integration is not making any HTTP requests.
strict-typing: todo

View File

@@ -1,30 +0,0 @@
{
"config": {
"step": {
"confirm_from_user": {
"description": "iNELS devices must be connected to the same broker as the Home Assistant MQTT integration client. Continue setup?"
},
"confirm_from_mqtt": {
"description": "Do you want to set up iNELS?"
}
},
"abort": {
"mqtt_not_connected": "Home Assistant MQTT integration not connected to MQTT broker.",
"mqtt_not_configured": "Home Assistant MQTT integration not configured.",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
}
},
"entity": {
"switch": {
"bit": {
"name": "Bit{addr}"
},
"simple_relay": {
"name": "Simple relay{index}"
},
"relay": {
"name": "Relay{index}"
}
}
}
}

View File

@@ -1,137 +0,0 @@
"""iNELS switch entity."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from inelsmqtt.devices import Device
from inelsmqtt.utils.common import Bit, Relay, SimpleRelay
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import InelsConfigEntry
from .entity import InelsBaseEntity
@dataclass(frozen=True, kw_only=True)
class InelsSwitchEntityDescription(SwitchEntityDescription):
"""Class describing iNELS switch entities."""
get_state_fn: Callable[[Device, int], Bit | SimpleRelay | Relay]
alerts: list[str] | None = None
placeholder_fn: Callable[[Device, int, bool], dict[str, str]]
SWITCH_TYPES = [
InelsSwitchEntityDescription(
key="bit",
translation_key="bit",
get_state_fn=lambda device, index: device.state.bit[index],
placeholder_fn=lambda device, index, indexed: {
"addr": f" {device.state.bit[index].addr}"
},
),
InelsSwitchEntityDescription(
key="simple_relay",
translation_key="simple_relay",
get_state_fn=lambda device, index: device.state.simple_relay[index],
placeholder_fn=lambda device, index, indexed: {
"index": f" {index + 1}" if indexed else ""
},
),
InelsSwitchEntityDescription(
key="relay",
translation_key="relay",
get_state_fn=lambda device, index: device.state.relay[index],
alerts=["overflow"],
placeholder_fn=lambda device, index, indexed: {
"index": f" {index + 1}" if indexed else ""
},
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: InelsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load iNELS switch."""
entities: list[InelsSwitch] = []
for device in entry.runtime_data.devices:
for description in SWITCH_TYPES:
if hasattr(device.state, description.key):
switch_count = len(getattr(device.state, description.key))
entities.extend(
InelsSwitch(
device=device,
description=description,
index=idx,
switch_count=switch_count,
)
for idx in range(switch_count)
)
async_add_entities(entities, False)
class InelsSwitch(InelsBaseEntity, SwitchEntity):
"""The platform class required by Home Assistant."""
entity_description: InelsSwitchEntityDescription
def __init__(
self,
device: Device,
description: InelsSwitchEntityDescription,
index: int = 0,
switch_count: int = 1,
) -> None:
"""Initialize the switch."""
super().__init__(device=device, key=description.key, index=index)
self.entity_description = description
self._switch_count = switch_count
# Include index in unique_id for devices with multiple switches
unique_key = f"{description.key}{index}" if index else description.key
self._attr_unique_id = f"{self._attr_unique_id}_{unique_key}".lower()
# Set translation placeholders
self._attr_translation_placeholders = self.entity_description.placeholder_fn(
self._device, self._index, self._switch_count > 1
)
def _check_alerts(self, current_state: Bit | SimpleRelay | Relay) -> None:
"""Check if there are active alerts and raise ServiceValidationError if found."""
if self.entity_description.alerts and any(
getattr(current_state, alert_key, None)
for alert_key in self.entity_description.alerts
):
raise ServiceValidationError("Cannot operate switch with active alerts")
@property
def is_on(self) -> bool | None:
"""Return if switch is on."""
current_state = self.entity_description.get_state_fn(self._device, self._index)
return current_state.is_on
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the switch to turn off."""
current_state = self.entity_description.get_state_fn(self._device, self._index)
self._check_alerts(current_state)
current_state.is_on = False
await self._device.set_ha_value(self._device.state)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the switch to turn on."""
current_state = self.entity_description.get_state_fn(self._device, self._index)
self._check_alerts(current_state)
current_state.is_on = True
await self._device.set_ha_value(self._device.state)

View File

@@ -65,8 +65,6 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = (
attr_fn=lambda info: {
"hebrew_year": str(info.hdate.year),
"hebrew_month_name": str(info.hdate.month),
"hebrew_month_standard_order": str(info.hdate.month.value),
"hebrew_month_biblical_order": str(info.hdate.month.biblical_order),
"hebrew_day": str(info.hdate.day),
},
),

View File

@@ -26,12 +26,6 @@
"state_attributes": {
"hebrew_year": { "name": "Hebrew year" },
"hebrew_month_name": { "name": "Hebrew month name" },
"hebrew_month_standard_order": {
"name": "Hebrew month in standard order"
},
"hebrew_month_biblical_order": {
"name": "Hebrew month in biblical order"
},
"hebrew_day": { "name": "Hebrew day" }
}
},

View File

@@ -48,8 +48,6 @@ from homeassistant.util.network import is_link_local
from .const import DOMAIN, LOGGER
DEVICES_URL = "https://developer.lametric.com/user/devices"
class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a LaMetric config flow."""
@@ -166,9 +164,6 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
return self.async_show_form(
step_id="manual_entry",
data_schema=vol.Schema(schema),
description_placeholders={
"devices_url": DEVICES_URL,
},
errors=errors,
)

View File

@@ -24,7 +24,7 @@
},
"data_description": {
"host": "The IP address or hostname of your LaMetric TIME on your network.",
"api_key": "You can find this API key in the [devices page in your LaMetric developer account]({devices_url})."
"api_key": "You can find this API key in the [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)."
}
},
"cloud_select_device": {

View File

@@ -18,7 +18,6 @@ from homeassistant.const import (
CONF_SOURCE,
CONF_UNIT_OF_MEASUREMENT,
LIGHT_LUX,
PERCENTAGE,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfSpeed,
@@ -51,7 +50,6 @@ DEVICE_CLASS_MAPPING = {
pypck.lcn_defs.VarUnit.VOLT: SensorDeviceClass.VOLTAGE,
pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT,
pypck.lcn_defs.VarUnit.PPM: SensorDeviceClass.CO2,
pypck.lcn_defs.VarUnit.PERCENT: SensorDeviceClass.HUMIDITY,
}
UNIT_OF_MEASUREMENT_MAPPING = {
@@ -64,7 +62,6 @@ UNIT_OF_MEASUREMENT_MAPPING = {
pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT,
pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE,
pypck.lcn_defs.VarUnit.PPM: CONCENTRATION_PARTS_PER_MILLION,
pypck.lcn_defs.VarUnit.PERCENT: PERCENTAGE,
}

View File

@@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "bronze",
"requirements": ["pylitterbot==2024.2.7"]
"requirements": ["pylitterbot==2024.2.4"]
}

View File

@@ -5,17 +5,11 @@ from __future__ import annotations
import asyncio
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ColorMode,
LightEntity,
brightness_supported,
)
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .const import DOMAIN
from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator
@@ -48,10 +42,8 @@ class LunatoneLight(
):
"""Representation of a Lunatone light."""
BRIGHTNESS_SCALE = (1, 100)
_last_brightness = 255
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
@@ -90,25 +82,6 @@ class LunatoneLight(
"""Return True if light is on."""
return self._device is not None and self._device.is_on
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
if self._device is None:
return 0
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
if self._device is not None and self._device.is_dimmable:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@property
def supported_color_modes(self) -> set[ColorMode]:
"""Return the supported color modes."""
return {self.color_mode}
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
@@ -118,27 +91,13 @@ class LunatoneLight(
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
assert self._device
if brightness_supported(self.supported_color_modes):
brightness = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness)
await self._device.fade_to_brightness(
brightness_to_value(self.BRIGHTNESS_SCALE, brightness)
)
else:
await self._device.switch_on()
await self._device.switch_on()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
assert self._device
if brightness_supported(self.supported_color_modes):
self._last_brightness = self.brightness
await self._device.fade_to_brightness(0)
else:
await self._device.switch_off()
await self._device.switch_off()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()

View File

@@ -36,21 +36,6 @@ if TYPE_CHECKING:
LOGGER = logging.getLogger(__name__)
# Due to variances in labeling implementations, labels are vendor and product specific.
# This dictionary defines which labels to use for specific vendor/product combinations.
# The keys are vendor IDs, the values are dictionaries with product IDs as keys
# and lists of label names to use as values. If the value is None, no labels are used
VENDOR_LABELING_LIST: dict[int, dict[int, list[str] | None]] = {
4488: {259: ["position"]}, # TP-Link Dual Outdoor Plug US
4874: {105: ["orientation"]}, # Eve Energy dual Outlet US
4961: {
1: ["inovelliname", "label", "name", "button"], # Inovelli VTM31
2: ["label", "devicetype", "button"], # Inovelli VTM35
4: None, # Inovelli VTM36
16: ["label", "name", "button"], # Inovelli VTM30
},
}
def catch_matter_error[_R, **P](
func: Callable[Concatenate[MatterEntity, P], Coroutine[Any, Any, _R]],
@@ -127,47 +112,30 @@ class MatterEntity(Entity):
if self._platform_translation_key and not self.translation_key:
self._attr_translation_key = self._platform_translation_key
# Matter labels can be used to modify the entity name
# by appending the text.
if name_modifier := self._get_name_modifier():
self._name_postfix = name_modifier
# prefer the label attribute for the entity name
# Matter has a way for users and/or vendors to specify a name for an endpoint
# which is always preferred over a standard HA (generated) name
for attr in (
clusters.FixedLabel.Attributes.LabelList,
clusters.UserLabel.Attributes.LabelList,
):
if not (labels := self.get_matter_attribute_value(attr)):
continue
for label in labels:
if label.label not in ["Label", "Button"]:
continue
# fixed or user label found: use it
label_value: str = label.value
# in the case the label is only the label id, use it as postfix only
if label_value.isnumeric():
self._name_postfix = label_value
else:
self._attr_name = label_value
break
# make sure to update the attributes once
self._update_from_device()
def _find_matching_labels(self) -> list[str]:
"""Find all labels for a Matter entity."""
device_info = self._endpoint.device_info
labeling_list = VENDOR_LABELING_LIST.get(device_info.vendorID, {}).get(
device_info.productID
)
# get the labels from the UserLabel and FixedLabel clusters
user_label_list: list[clusters.UserLabel.Structs.LabelStruct] = (
self.get_matter_attribute_value(clusters.UserLabel.Attributes.LabelList)
or []
)
fixed_label_list: list[clusters.FixedLabel.Structs.LabelStruct] = (
self.get_matter_attribute_value(clusters.FixedLabel.Attributes.LabelList)
or []
)
found_labels: list[str] = [
lbl.value
for label in labeling_list or []
for lbl in (*user_label_list, *fixed_label_list)
if lbl.label.lower() == label
]
return found_labels
def _get_name_modifier(self) -> str | None:
"""Get the name modifier for the entity."""
if found_labels := self._find_matching_labels():
return found_labels[0]
return None
async def async_added_to_hass(self) -> None:
"""Handle being added to Home Assistant."""
await super().async_added_to_hass()

View File

@@ -123,9 +123,6 @@
"evse_fault_state": {
"default": "mdi:ev-station"
},
"operational_error": {
"default": "mdi:alert-circle"
},
"pump_control_mode": {
"default": "mdi:pipe-wrench"
},

View File

@@ -86,14 +86,6 @@ OPERATIONAL_STATE_MAP = {
clusters.OperationalState.Enums.OperationalStateEnum.kError: "error",
}
OPERATIONAL_STATE_ERROR_MAP = {
# enum with known Error state values which we can translate
clusters.OperationalState.Enums.ErrorStateEnum.kNoError: "no_error",
clusters.OperationalState.Enums.ErrorStateEnum.kUnableToStartOrResume: "unable_to_start_or_resume",
clusters.OperationalState.Enums.ErrorStateEnum.kUnableToCompleteOperation: "unable_to_complete_operation",
clusters.OperationalState.Enums.ErrorStateEnum.kCommandInvalidInState: "command_invalid_in_state",
}
RVC_OPERATIONAL_STATE_MAP = {
# enum with known Operation state values which we can translate
**OPERATIONAL_STATE_MAP,
@@ -102,29 +94,6 @@ RVC_OPERATIONAL_STATE_MAP = {
clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked",
}
RVC_OPERATIONAL_STATE_ERROR_MAP = {
# enum with known Error state values which we can translate
clusters.RvcOperationalState.Enums.ErrorStateEnum.kNoError: "no_error",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kUnableToStartOrResume: "unable_to_start_or_resume",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kUnableToCompleteOperation: "unable_to_complete_operation",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kCommandInvalidInState: "command_invalid_in_state",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kFailedToFindChargingDock: "failed_to_find_charging_dock",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kStuck: "stuck",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kDustBinMissing: "dust_bin_missing",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kDustBinFull: "dust_bin_full",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankEmpty: "water_tank_empty",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankMissing: "water_tank_missing",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankLidOpen: "water_tank_lid_open",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kMopCleaningPadMissing: "mop_cleaning_pad_missing",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kLowBattery: "low_battery",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kCannotReachTargetArea: "cannot_reach_target_area",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kDirtyWaterTankFull: "dirty_water_tank_full",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kDirtyWaterTankMissing: "dirty_water_tank_missing",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kWheelsJammed: "wheels_jammed",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kBrushJammed: "brush_jammed",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kNavigationSensorObscured: "navigation_sensor_obscured",
}
BOOST_STATE_MAP = {
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kInactive: "inactive",
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: "active",
@@ -1132,19 +1101,6 @@ DISCOVERY_SCHEMAS = [
# don't discover this entry if the supported state list is empty
secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="OperationalStateOperationalError",
translation_key="operational_error",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(OPERATIONAL_STATE_ERROR_MAP.values()),
device_to_ha=lambda x: OPERATIONAL_STATE_ERROR_MAP.get(x.errorStateID),
),
entity_class=MatterSensor,
required_attributes=(clusters.OperationalState.Attributes.OperationalError,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterListSensorEntityDescription(
@@ -1189,19 +1145,6 @@ DISCOVERY_SCHEMAS = [
device_type=(device_types.Thermostat,),
allow_multi=True, # also used for climate entity
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ThermostatPIHeatingDemand",
translation_key="pi_heating_demand",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.PIHeatingDemand,),
device_type=(device_types.Thermostat,),
featuremap_contains=clusters.Thermostat.Bitmaps.Feature.kHeating,
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
@@ -1238,19 +1181,6 @@ DISCOVERY_SCHEMAS = [
# don't discover this entry if the supported state list is empty
secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="RvcOperationalStateOperationalError",
translation_key="operational_error",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(RVC_OPERATIONAL_STATE_ERROR_MAP.values()),
device_to_ha=lambda x: RVC_OPERATIONAL_STATE_ERROR_MAP.get(x.errorStateID),
),
entity_class=MatterSensor,
required_attributes=(clusters.RvcOperationalState.Attributes.OperationalError,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterOperationalStateSensorEntityDescription(

View File

@@ -441,33 +441,6 @@
"evse_soc": {
"name": "State of charge"
},
"operational_error": {
"name": "Operational error",
"state": {
"no_error": "No error",
"unable_to_start_or_resume": "Unable to start or resume",
"unable_to_complete_operation": "Unable to complete operation",
"command_invalid_in_state": "Command invalid in current state",
"failed_to_find_charging_dock": "Failed to find charging dock",
"stuck": "Stuck",
"dust_bin_missing": "Dust bin missing",
"dust_bin_full": "Dust bin full",
"water_tank_empty": "Water tank empty",
"water_tank_missing": "Water tank missing",
"water_tank_lid_open": "Water tank lid open",
"mop_cleaning_pad_missing": "Mop cleaning pad missing",
"low_battery": "Low battery",
"cannot_reach_target_area": "Cannot reach target area",
"dirty_water_tank_full": "Dirty water tank full",
"dirty_water_tank_missing": "Dirty water tank missing",
"wheels_jammed": "Wheels jammed",
"brush_jammed": "Brush jammed",
"navigation_sensor_obscured": "Navigation sensor obscured"
}
},
"pi_heating_demand": {
"name": "Heating demand"
},
"nitrogen_dioxide": {
"name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]"
},

View File

@@ -256,8 +256,7 @@ DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
platform=Platform.UPDATE,
entity_description=MatterUpdateEntityDescription(
key="MatterUpdate",
device_class=UpdateDeviceClass.FIRMWARE,
key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE
),
entity_class=MatterUpdate,
required_attributes=(

View File

@@ -47,8 +47,6 @@ MCP_DISCOVERY_HEADERS = {
"MCP-Protocol-Version": "2025-03-26",
}
EXAMPLE_URL = "http://example/sse"
@dataclass
class OAuthConfig:
@@ -184,10 +182,7 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={"example_url": EXAMPLE_URL},
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_auth_discovery(

View File

@@ -6,7 +6,7 @@
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "The remote MCP server URL for the SSE endpoint, for example {example_url}"
"url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse"
}
},
"credentials_choice": {
@@ -35,7 +35,7 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"invalid_url": "Must be a valid MCP server URL"
"invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",

View File

@@ -270,13 +270,10 @@ class MetOfficeWeather(
self.forecast_coordinators["daily"],
)
timesteps = coordinator.data.timesteps
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
hour=0, minute=0, second=0, microsecond=0
)
return [
_build_daily_forecast_data(timestep)
for timestep in timesteps
if timestep["time"] >= start_datetime
if timestep["time"] > datetime.now(tz=timesteps[0]["time"].tzinfo)
]
@callback
@@ -288,13 +285,10 @@ class MetOfficeWeather(
)
timesteps = coordinator.data.timesteps
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
minute=0, second=0, microsecond=0
)
return [
_build_hourly_forecast_data(timestep)
for timestep in timesteps
if timestep["time"] >= start_datetime
if timestep["time"] > datetime.now(tz=timesteps[0]["time"].tzinfo)
]
@callback
@@ -305,11 +299,8 @@ class MetOfficeWeather(
self.forecast_coordinators["twice_daily"],
)
timesteps = coordinator.data.timesteps
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
hour=0, minute=0, second=0, microsecond=0
)
return [
_build_twice_daily_forecast_data(timestep)
for timestep in timesteps
if timestep["time"] >= start_datetime
if timestep["time"] > datetime.now(tz=timesteps[0]["time"].tzinfo)
]

View File

@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["dnspython", "mcstatus"],
"quality_scale": "silver",
"requirements": ["mcstatus==12.0.6"]
"requirements": ["mcstatus==12.0.1"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "legacy",
"requirements": ["nsapi==3.1.3"]
"requirements": ["nsapi==3.1.2"]
}

View File

@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["nextdns"],
"quality_scale": "silver",
"quality_scale": "bronze",
"requirements": ["nextdns==4.1.0"]
}

View File

@@ -37,7 +37,9 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
test-coverage:
status: todo
comment: Patch NextDns object instead of functions.
# Gold
devices: done
@@ -48,17 +50,19 @@ rules:
discovery:
status: exempt
comment: The integration is a cloud service and thus does not support discovery.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations:
status: todo
comment: Add info that there are no known limitations.
docs-supported-devices:
status: exempt
comment: This is a service, which doesn't integrate with any devices.
docs-supported-functions: done
docs-supported-functions: todo
docs-troubleshooting:
status: exempt
comment: No known issues that could be resolved by the user.
docs-use-cases: done
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: This integration has a fixed single service.

View File

@@ -320,7 +320,7 @@
"name": "Block WhatsApp"
},
"block_xboxlive": {
"name": "Block Xbox Network"
"name": "Block Xbox Live"
},
"block_youtube": {
"name": "Block YouTube"

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers import entity_registry as er
from .const import _LOGGER
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SCENE]
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT]
type NikoHomeControlConfigEntry = ConfigEntry[NHCController]

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