Compare commits

..

2 Commits

Author SHA1 Message Date
J. Nick Koston 974b5ece19 Verify state_changed unsubscribe is invoked on overflow
Add a spy on async_track_state_change_event so the test directly
asserts the live stream's unsubscribe callable is invoked when the
queue overflows, instead of relying on the (unreliable) shared
EVENT_STATE_CHANGED bus listener count.
2026-05-21 14:21:35 -05:00
J. Nick Koston 47d21a7204 Fix flaky test_overflow_queue in history websocket tests
Comparing the full bus listener dict can fail when unrelated components
asynchronously add or remove EVENT_STATE_CHANGED listeners during the
test; capture listeners before and after via a context manager that
excludes state_changed.
2026-05-21 14:08:03 -05:00
270 changed files with 1758 additions and 14457 deletions
-1
View File
@@ -15,7 +15,6 @@ Dockerfile.dev linguist-language=Dockerfile
# Generated files
CODEOWNERS linguist-generated=true
homeassistant/generated/*.py linguist-generated=true
pylint/plugins/pylint_home_assistant/generated/*.py linguist-generated=true
machine/* linguist-generated=true
mypy.ini linguist-generated=true
requirements.txt linguist-generated=true
-1
View File
@@ -25,7 +25,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
+3 -40
View File
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +302,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
with:
extra-args: --all-files zizmor
@@ -917,49 +917,12 @@ jobs:
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore pytest test counts cache
id: cache-pytest-counts
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: pytest_test_counts.json
# Primary key is a sentinel; restore-keys pick the most recent
# prefix match since the real (content-addressed) key isn't
# known until split_tests.py runs below.
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-restore-sentinel
restore-keys: |
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }}-
- name: Run split_tests.py
env:
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
run: |
. venv/bin/activate
python -m script.split_tests \
--cache pytest_test_counts.json \
${TEST_GROUP_COUNT} tests
- name: Hash pytest test counts cache
id: cache-pytest-counts-hash
run: |
echo "hash=$(sha256sum pytest_test_counts.json | cut -d' ' -f1)" \
>> "$GITHUB_OUTPUT"
- name: Save pytest test counts cache
# Content-addressed key: identical content reuses the same entry.
# Skip the save when the restore already matched that hash.
if: >-
!endsWith(
steps.cache-pytest-counts.outputs.cache-matched-key,
steps.cache-pytest-counts-hash.outputs.hash
)
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: pytest_test_counts.json
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-${{
steps.cache-pytest-counts-hash.outputs.hash }}
python -m script.split_tests ${TEST_GROUP_COUNT} tests
- name: Upload pytest_buckets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: "/language:python"
-1
View File
@@ -15,7 +15,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
@@ -11,7 +11,7 @@
"service": "mdi:dialpad"
},
"alarm_toggle_chime": {
"service": "mdi:bell-ring"
"service": "mdi:abc"
}
}
}
@@ -91,6 +91,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
translation_placeholders={"error": repr(err)},
) from err
except CannotAuthenticate as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
@@ -102,9 +102,6 @@
"entry_not_loaded": {
"message": "Entry not loaded: {entry}"
},
"invalid_auth": {
"message": "Invalid authentication credentials: {error}"
},
"invalid_device_id": {
"message": "Invalid device ID specified: {device_id}"
},
@@ -20,7 +20,7 @@
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.29.11",
"dbus-fast==5.0.3",
"habluetooth==6.2.0"
"dbus-fast==5.0.0",
"habluetooth==6.1.0"
]
}
@@ -65,9 +65,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
translation_placeholders={"error": repr(err)},
) from err
except aiocomelit_exceptions.CannotAuthenticate as err:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise InvalidAuth(
translation_domain=DOMAIN,
translation_key="cannot_authenticate",
translation_placeholders={"error": repr(err)},
) from err
finally:
await api.logout()
@@ -61,7 +61,7 @@ class CurrencylayerSensor(SensorEntity):
"""Implementing the Currencylayer sensor."""
_attr_attribution = "Data provided by currencylayer.com"
_attr_icon = "mdi:currency-usd"
_attr_icon = "mdi:currency"
def __init__(self, rest: CurrencylayerData, base: str, quote: str) -> None:
"""Initialize the sensor."""
+2 -2
View File
@@ -26,12 +26,12 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_LOCKED, ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .const import ATTR_OFFSET, ATTR_VALVE
from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE
from .entity import DeconzDevice
from .hub import DeconzHub
+2
View File
@@ -43,6 +43,8 @@ PLATFORMS = [
]
ATTR_DARK = "dark"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCKED = "locked"
ATTR_OFFSET = "offset"
ATTR_ON = "on"
ATTR_VALVE = "valve"
+1 -1
View File
@@ -16,7 +16,7 @@
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.1",
"aiodiscover==3.2.3",
"aiodiscover==3.2.0",
"cached-ipaddress==1.0.1"
]
}
+6 -1
View File
@@ -6,6 +6,7 @@ import logging
import aiodns
from aiodns.error import DNSError
from pycares import AresError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
@@ -77,7 +78,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
) from err
errors = [
result for result in results if isinstance(result, (TimeoutError, DNSError))
result
for result in results
if isinstance(
result, (TimeoutError, DNSError, AresError, asyncio.CancelledError)
)
]
if errors and len(errors) == len(results):
await _close_resolvers()
+1 -1
View File
@@ -23,7 +23,7 @@
"service": "mdi:refresh"
},
"set_dhw_override": {
"service": "mdi:water-boiler"
"service": "mdi:water-heater"
},
"set_system_mode": {
"service": "mdi:pencil"
+1 -1
View File
@@ -16,7 +16,7 @@ class DeviceType(Enum):
GAME_CONSOLE = "mdi:nintendo-game-boy"
STREAMING_DONGLE = "mdi:cast"
LOUDSPEAKER = SOUND_SYSTEM = STB = SATELLITE = MUSIC = "mdi:speaker"
DISC_PLAYER = "mdi:disc-player"
DISC_PLAYER = "mdi:disk-player"
REMOTE_CONTROL = "mdi:remote-tv"
RADIO = "mdi:radio"
PHOTO_CAMERA = PHOTOS = "mdi:camera"
+1
View File
@@ -10,6 +10,7 @@ from .coordinator import FloDeviceDataUpdateCoordinator
class FloEntity(Entity):
"""A base class for Flo entities."""
_attr_force_update = False
_attr_has_entity_name = True
_attr_should_poll = False
+1 -1
View File
@@ -2,7 +2,7 @@
"entity": {
"button": {
"sync_clock": {
"default": "mdi:clock-check"
"default": "mdi:clock-sync"
}
},
"number": {
@@ -14,5 +14,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["guntamatic==1.9.0"]
"requirements": ["guntamatic==1.8.0"]
}
+2
View File
@@ -2,6 +2,8 @@
from homeassistant.const import Platform
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODE = "mode"
ATTR_TIME_PERIOD = "time_period"
ATTR_ONOFF = "on_off"
CONF_CODE = "2fa"
+1 -1
View File
@@ -12,12 +12,12 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
)
from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from . import HiveConfigEntry, refresh_system
from .const import ATTR_MODE
from .entity import HiveEntity
PARALLEL_UPDATES = 0
+2 -1
View File
@@ -6,11 +6,12 @@ from typing import Any
from apyhiveapi import Hive
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import ATTR_MODE, EntityCategory
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HiveConfigEntry, refresh_system
from .const import ATTR_MODE
from .entity import HiveEntity
PARALLEL_UPDATES = 0
@@ -19,9 +19,7 @@ EXPECTED_ENTRY_VERSION = (
@callback
def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
"""Return board info."""
entries = hass.config_entries.async_entries(
DOMAIN, include_ignore=False, include_disabled=False
)
entries = hass.config_entries.async_entries(DOMAIN)
return [
HardwareInfo(
board=None,
@@ -13,6 +13,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
"requirements": ["python-homewizard-energy==10.1.0"],
"requirements": ["python-homewizard-energy==10.0.1"],
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
}
+10 -2
View File
@@ -35,6 +35,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
from .const import DOMAIN
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
@@ -65,6 +66,13 @@ def to_percentage(value: float | None) -> float | None:
return value * 100 if value is not None else None
def uptime_to_datetime(value: int) -> datetime:
"""Convert seconds to datetime timestamp."""
return utcnow().replace(microsecond=0) - timedelta(seconds=value)
uptime_to_stable_datetime = ignore_variance(uptime_to_datetime, timedelta(minutes=5))
SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
HomeWizardSensorEntityDescription(
key="smr_version",
@@ -635,7 +643,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
HomeWizardSensorEntityDescription(
key="uptime",
translation_key="uptime",
device_class=SensorDeviceClass.UPTIME,
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
has_fn=(
@@ -643,7 +651,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
),
value_fn=(
lambda data: (
utcnow() - timedelta(seconds=data.system.uptime_s)
uptime_to_stable_datetime(data.system.uptime_s)
if data.system is not None and data.system.uptime_s is not None
else None
)
@@ -61,14 +61,13 @@
},
"select": {
"battery_group_mode": {
"name": "Battery group charging strategy",
"name": "Battery group mode",
"state": {
"predictive": "Smart charging",
"standby": "Standby",
"to_full": "One-time full charge",
"zero": "Net zero",
"zero_charge_only": "Net zero (charge only)",
"zero_discharge_only": "Net zero (discharge only)"
"to_full": "Manual charge mode",
"zero": "Zero mode",
"zero_charge_only": "Zero mode (charge only)",
"zero_discharge_only": "Zero mode (discharge only)"
}
}
},
+12 -13
View File
@@ -31,16 +31,15 @@ activate_scene:
dynamic:
selector:
boolean:
scene_customization:
collapsed: true
fields:
speed:
selector:
number:
min: 0
max: 100
brightness:
selector:
number:
min: 1
max: 255
speed:
advanced: true
selector:
number:
min: 0
max: 100
brightness:
advanced: true
selector:
number:
min: 1
max: 255
+1 -6
View File
@@ -184,12 +184,7 @@
"name": "Transition"
}
},
"name": "Activate Hue scene",
"sections": {
"scene_customization": {
"name": "Scene customization"
}
}
"name": "Activate Hue scene"
},
"hue_activate_scene": {
"description": "Activates a Hue scene stored in the Hue hub.",
@@ -87,8 +87,6 @@ def async_get_triggers(
# Get Hue device id from device identifier
hue_dev_id = get_hue_device_id(device_entry)
if hue_dev_id is None or hue_dev_id not in api.devices:
return []
# extract triggers from all button resources of this Hue device
triggers: list[dict[str, Any]] = []
model_id = api.devices[hue_dev_id].product_data.product_name
+2 -2
View File
@@ -118,8 +118,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
device = devices.add_x10_device(housecode, unitcode, x10_type, steps)
create_insteon_device(hass, devices.modem, entry.entry_id)
await hass.config_entries.async_forward_entry_setups(entry, INSTEON_PLATFORMS)
for address in devices:
@@ -133,6 +131,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
register_new_device_callback(hass)
async_setup_services(hass)
create_insteon_device(hass, devices.modem, entry.entry_id)
entry.async_create_background_task(
hass, async_get_device_config(hass, entry), "insteon-get-device-config"
)
@@ -1,7 +1,7 @@
{
"entity": {
"binary_sensor": {
"erev_shabbat_hag": { "default": "mdi:candle" },
"erev_shabbat_hag": { "default": "mdi:candle-light" },
"issur_melacha_in_effect": { "default": "mdi:power-plug-off" },
"motzei_shabbat_hag": { "default": "mdi:fire" }
},
+1 -1
View File
@@ -7,7 +7,7 @@
"service": "mdi:lock-open"
},
"disable": {
"service": "mdi:flash-off"
"service": "mdi:fash-off"
},
"enable": {
"service": "mdi:flash"
+4 -4
View File
@@ -28,25 +28,25 @@
"ice_maker": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-off-outline"
"off": "mdi:cube-outline-off"
}
},
"ice_maker_bottom_zone": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-off-outline"
"off": "mdi:cube-outline-off"
}
},
"ice_maker_middle_zone": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-off-outline"
"off": "mdi:cube-outline-off"
}
},
"ice_maker_top_zone": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-off-outline"
"off": "mdi:cube-outline-off"
}
}
},
+1 -1
View File
@@ -241,7 +241,7 @@ def preprocess_turn_on_alternatives(
if (color_name := params.pop(ATTR_COLOR_NAME, None)) is not None:
try:
params[ATTR_RGB_COLOR] = tuple(color_util.color_name_to_rgb(color_name))
params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)
except ValueError:
_LOGGER.warning("Got unknown color %s, falling back to white", color_name)
params[ATTR_RGB_COLOR] = (255, 255, 255)
@@ -3,7 +3,7 @@
from datetime import timedelta
from typing import Any
from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED, BridgeResponseError
from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -131,11 +131,7 @@ class LutronCasetaBatterySensor(LutronCasetaEntity, BinarySensorEntity):
async def async_update(self) -> None:
"""Fetch the latest battery status from the bridge."""
try:
status = await self._smartbridge.get_battery_status(self.device_id)
except BridgeResponseError:
self._attr_is_on = None
return
status = await self._smartbridge.get_battery_status(self.device_id)
normalized_status = status.strip().casefold() if status else None
if normalized_status == BATTERY_STATUS_LOW:
self._attr_is_on = True
+1 -1
View File
@@ -60,7 +60,7 @@ def get_matter_device_info(
return None
return MatterDeviceInfo(
unique_id=node.device_info.uniqueID or "",
unique_id=node.device_info.uniqueID,
vendor_id=hex(node.device_info.vendorID),
product_id=hex(node.device_info.productID),
)
@@ -6,14 +6,13 @@ from typing import Any
from chip.clusters import Objects
from matter_server.common.helpers.util import dataclass_to_dict, parse_attribute_path
from homeassistant.components.diagnostics import REDACTED, async_redact_data
from homeassistant.components.diagnostics import REDACTED
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .helpers import MatterConfigEntry, get_matter, get_node_from_device_entry
ATTRIBUTES_TO_REDACT = {Objects.BasicInformation.Attributes.Location}
SERVER_INFO_TO_REDACT = {"wifi_ssid"}
def redact_matter_attributes(node_data: dict[str, Any]) -> dict[str, Any]:
@@ -45,7 +44,6 @@ async def async_get_config_entry_diagnostics(
matter = get_matter(hass)
server_diagnostics = await matter.matter_client.get_diagnostics()
data = dataclass_to_dict(server_diagnostics)
data["info"] = async_redact_data(data["info"], SERVER_INFO_TO_REDACT)
nodes = [redact_matter_attributes(node_data) for node_data in data["nodes"]]
data["nodes"] = nodes
@@ -61,9 +59,7 @@ async def async_get_device_diagnostics(
node = get_node_from_device_entry(hass, device)
return {
"server_info": async_redact_data(
dataclass_to_dict(server_diagnostics.info), SERVER_INFO_TO_REDACT
),
"server_info": dataclass_to_dict(server_diagnostics.info),
"node": redact_matter_attributes(
remove_serialization_type(dataclass_to_dict(node.node_data) if node else {})
),
+1 -1
View File
@@ -102,7 +102,7 @@
"default": "mdi:home-lightning-bolt"
},
"eve_weather_trend": {
"default": "mdi:weather-cloudy",
"default": "mdi:weather",
"state": {
"cloudy": "mdi:weather-cloudy",
"rainy": "mdi:weather-rainy",
@@ -8,6 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/matter",
"integration_type": "hub",
"iot_class": "local_push",
"requirements": ["matter-python-client==0.7.1"],
"requirements": ["matter-python-client==0.6.0"],
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
}
+1 -2
View File
@@ -4,13 +4,12 @@ from dataclasses import dataclass
from typing import cast
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow, llm
from .application_credentials import authorization_server_context
from .const import CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .coordinator import ModelContextProtocolCoordinator, TokenManager
from .types import ModelContextProtocolConfigEntry
+8 -2
View File
@@ -13,7 +13,7 @@ from yarl import URL
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
from homeassistant.const import CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -24,7 +24,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from . import async_get_config_entry_implementation
from .application_credentials import authorization_server_context
from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN
from .const import (
CONF_ACCESS_TOKEN,
CONF_AUTHORIZATION_URL,
CONF_SCOPE,
CONF_TOKEN_URL,
DOMAIN,
)
from .coordinator import TokenManager, mcp_client
_LOGGER = logging.getLogger(__name__)
+2
View File
@@ -2,6 +2,8 @@
DOMAIN = "mcp"
# pylint: disable-next=home-assistant-duplicate-const
CONF_ACCESS_TOKEN = "access_token"
CONF_AUTHORIZATION_URL = "authorization_url"
CONF_TOKEN_URL = "token_url"
CONF_SCOPE = "scope"
+5 -1
View File
@@ -41,7 +41,7 @@ from mcp.shared.message import SessionMessage
from homeassistant.components import conversation
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import llm
@@ -56,6 +56,10 @@ _LOGGER = logging.getLogger(__name__)
STREAMABLE_API = "/api/mcp"
TIMEOUT = 60 # Seconds
# Content types
# pylint: disable-next=home-assistant-duplicate-const
CONTENT_TYPE_JSON = "application/json"
# Legacy SSE endpoint
SSE_API = f"/{DOMAIN}/sse"
MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}"
@@ -108,7 +108,6 @@ ABBREVIATIONS = {
"mode_stat_t": "mode_state_topic",
"mode_stat_tpl": "mode_state_template",
"modes": "modes",
"msg_exp_int": "message_expiry_interval",
"name": "name",
"o": "origin",
"off_dly": "off_delay",
@@ -120,8 +120,6 @@ from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.selector import (
BooleanSelector,
DurationSelector,
DurationSelectorConfig,
FileSelector,
FileSelectorConfig,
NumberSelector,
@@ -229,7 +227,6 @@ from .const import (
CONF_LAST_RESET_VALUE_TEMPLATE,
CONF_MAX,
CONF_MAX_KELVIN,
CONF_MESSAGE_EXPIRY_INTERVAL,
CONF_MIN,
CONF_MIN_KELVIN,
CONF_MODE_COMMAND_TEMPLATE,
@@ -3724,11 +3721,6 @@ MQTT_DEVICE_PLATFORM_FIELDS = {
default=DEFAULT_QOS,
section="mqtt_settings",
),
CONF_MESSAGE_EXPIRY_INTERVAL: PlatformField(
selector=DurationSelector(DurationSelectorConfig(enable_day=True)),
required=False,
section="mqtt_settings",
),
}
-1
View File
@@ -49,7 +49,6 @@ CONF_IMAGE_TOPIC = "image_topic"
CONF_JSON_ATTRS_TOPIC = "json_attributes_topic"
CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template"
CONF_KEEPALIVE = "keepalive"
CONF_MESSAGE_EXPIRY_INTERVAL = "message_expiry_interval"
CONF_ORIGIN = "origin"
CONF_QOS = ATTR_QOS
CONF_RETAIN = ATTR_RETAIN
+2 -12
View File
@@ -17,7 +17,7 @@ from .models import DATA_MQTT, PublishPayloadType
STORED_MESSAGES = 10
@dataclass(frozen=True, slots=True)
@dataclass
class TimestampedPublishMessage:
"""MQTT Message."""
@@ -26,8 +26,6 @@ class TimestampedPublishMessage:
qos: int
retain: bool
timestamp: float
encoding: str | None
kwargs: dict[str, Any]
def log_message(
@@ -37,8 +35,6 @@ def log_message(
payload: PublishPayloadType,
qos: int,
retain: bool,
encoding: str | None,
**kwargs: Any,
) -> None:
"""Log an outgoing MQTT message."""
entity_info = hass.data[DATA_MQTT].debug_info_entities.setdefault(
@@ -49,13 +45,7 @@ def log_message(
"messages": deque(maxlen=STORED_MESSAGES),
}
msg = TimestampedPublishMessage(
topic,
payload,
qos,
retain,
timestamp=time.monotonic(),
encoding=encoding,
kwargs=kwargs,
topic, payload, qos, retain, timestamp=time.monotonic()
)
entity_info["transmitted"][topic]["messages"].append(msg)
+26 -12
View File
@@ -84,7 +84,6 @@ from .const import (
CONF_JSON_ATTRS_TEMPLATE,
CONF_JSON_ATTRS_TOPIC,
CONF_MANUFACTURER,
CONF_MESSAGE_EXPIRY_INTERVAL,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_QOS,
@@ -95,6 +94,7 @@ from .const import (
CONF_SW_VERSION,
CONF_TOPIC,
CONF_VIA_DEVICE,
DEFAULT_ENCODING,
DOMAIN,
MQTT_CONNECTION_STATE,
)
@@ -153,8 +153,6 @@ MQTT_ATTRIBUTES_BLOCKED = {
"unit_of_measurement",
}
PUBLISH_KWARGS = (CONF_MESSAGE_EXPIRY_INTERVAL,)
@callback
def async_handle_schema_error(
@@ -1541,20 +1539,36 @@ class MqttEntity(
await MqttDiscoveryUpdateMixin.async_will_remove_from_hass(self)
debug_info.remove_entity_data(self.hass, self.entity_id)
async def async_publish(
self,
topic: str,
payload: PublishPayloadType,
qos: int = 0,
retain: bool = False,
encoding: str | None = DEFAULT_ENCODING,
) -> None:
"""Publish message to an MQTT topic."""
log_message(self.hass, self.entity_id, topic, payload, qos, retain)
await async_publish(
self.hass,
topic,
payload,
qos,
retain,
encoding,
)
async def async_publish_with_config(
self, topic: str, payload: PublishPayloadType
) -> None:
"""Publish payload to a topic using config."""
kwargs: dict[str, Any] = {
key: value for key, value in self._config.items() if key in PUBLISH_KWARGS
}
qos: int = self._config[CONF_QOS]
retain: bool = self._config[CONF_RETAIN]
encoding: str = self._config[CONF_ENCODING]
log_message(
self.hass, self.entity_id, topic, payload, qos, retain, encoding, **kwargs
await self.async_publish(
topic,
payload,
self._config[CONF_QOS],
self._config[CONF_RETAIN],
self._config[CONF_ENCODING],
)
await async_publish(self.hass, topic, payload, qos, retain, encoding, **kwargs)
@staticmethod
@abstractmethod
-10
View File
@@ -509,20 +509,10 @@ class MqttComponentConfig:
discovery_payload: MQTTDiscoveryPayload
class MessageExpiryInterval(TypedDict, total=False):
"""Hold the Message Expiry Interval."""
days: float
hours: float
minutes: float
seconds: float
class DeviceMqttOptions(TypedDict, total=False):
"""Hold the shared MQTT specific options for an MQTT device."""
qos: int
message_expiry_interval: MessageExpiryInterval
class MqttDeviceData(TypedDict, total=False):
-12
View File
@@ -40,7 +40,6 @@ from .const import (
CONF_JSON_ATTRS_TEMPLATE,
CONF_JSON_ATTRS_TOPIC,
CONF_MANUFACTURER,
CONF_MESSAGE_EXPIRY_INTERVAL,
CONF_ORIGIN,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
@@ -67,7 +66,6 @@ SHARED_OPTIONS = [
CONF_AVAILABILITY_TOPIC,
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_MESSAGE_EXPIRY_INTERVAL,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_STATE_TOPIC,
@@ -163,14 +161,6 @@ MQTT_ORIGIN_INFO_SCHEMA = vol.All(
),
)
def valid_message_expiry_interval(value: Any) -> int:
"""Return Message Expiry Interval in seconds."""
if isinstance(value, int):
return cv.positive_int(value) # type: ignore[no-any-return]
return int(cv.positive_time_period_dict(value).total_seconds())
MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
{
vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA,
@@ -182,7 +172,6 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template,
vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string,
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
@@ -214,7 +203,6 @@ DEVICE_DISCOVERY_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
vol.Required(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA,
vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
vol.Optional(CONF_QOS): valid_qos_schema,
vol.Optional(CONF_ENCODING): cv.string,
}
@@ -197,11 +197,9 @@
},
"mqtt_settings": {
"data": {
"message_expiry_interval": "Message Expiry Interval",
"qos": "QoS"
},
"data_description": {
"message_expiry_interval": "Retention time interval for published message.",
"qos": "The Quality of Service value the device's entities should use."
},
"name": "MQTT settings"
@@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import ATTR_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -15,6 +14,7 @@ from .const import (
ATTR_DESCRIPTION,
ATTR_EXPIRES,
ATTR_HEADLINE,
ATTR_ID,
ATTR_RECOMMENDED_ACTIONS,
ATTR_SENDER,
ATTR_SENT,
+2
View File
@@ -29,6 +29,8 @@ ATTR_SEVERITY: str = "severity"
ATTR_RECOMMENDED_ACTIONS: str = "recommended_actions"
ATTR_AFFECTED_AREAS: str = "affected_areas"
ATTR_WEB: str = "web"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_ID: str = "id"
ATTR_SENT: str = "sent"
ATTR_START: str = "start"
ATTR_EXPIRES: str = "expires"
@@ -595,8 +595,8 @@ class OpenAIBaseLLMEntity(Entity):
)
)
if not model_args["model"].startswith("o"):
# o-series models handle this correctly with just a prompt
if "reasoning" not in model_args:
# Reasoning models handle this correctly with just a prompt
remove_citations = True
tools.append(web_search)
@@ -15,5 +15,5 @@
"iot_class": "local_push",
"loggers": ["opendisplay"],
"quality_scale": "silver",
"requirements": ["py-opendisplay==7.2.3"]
"requirements": ["py-opendisplay==5.9.0"]
}
@@ -218,7 +218,7 @@ async def _async_upload_image(call: ServiceCall) -> None:
pil_image,
refresh_mode=refresh_mode,
dither_mode=dither_mode,
tone=tone_compression,
tone_compression=tone_compression,
fit=fit_mode,
rotate=rotation,
)
@@ -37,15 +37,11 @@ class OpenhomeConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("async_step_ssdp: Incomplete discovery, ignoring")
return self.async_abort(reason="incomplete_discovery")
udn = discovery_info.upnp[ATTR_UPNP_UDN]
if isinstance(udn, list):
if not udn:
return self.async_abort(reason="incomplete_discovery")
udn = udn[0]
_LOGGER.debug(
"async_step_ssdp: setting unique id %s", discovery_info.upnp[ATTR_UPNP_UDN]
)
_LOGGER.debug("async_step_ssdp: setting unique id %s", udn)
await self.async_set_unique_id(udn)
await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN])
self._abort_if_unique_id_configured({CONF_HOST: discovery_info.ssdp_location})
_LOGGER.debug(
@@ -118,9 +118,6 @@
"services": {
"prune_images": {
"service": "mdi:delete-sweep"
},
"recreate_container": {
"service": "mdi:restart"
}
}
}
@@ -20,9 +20,6 @@ from .coordinator import PortainerConfigEntry
ATTR_DATE_UNTIL = "until"
ATTR_DANGLING = "dangling"
ATTR_TIMEOUT = "timeout"
ATTR_PULL_IMAGE = "pull_image"
ATTR_CONTAINER_DEVICE_ID = "container_device_id"
SERVICE_PRUNE_IMAGES = "prune_images"
SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
@@ -35,17 +32,6 @@ SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
},
)
SERVICE_RECREATE_CONTAINER = "recreate_container"
SERVICE_RECREATE_CONTAINER_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONTAINER_DEVICE_ID): cv.string,
vol.Optional(ATTR_TIMEOUT): vol.All(
cv.time_period, vol.Range(min=timedelta(minutes=1))
),
vol.Optional(ATTR_PULL_IMAGE): cv.boolean,
}
)
async def _extract_config_entry(service_call: ServiceCall) -> PortainerConfigEntry:
"""Extract config entry from the service call."""
@@ -89,45 +75,6 @@ async def _get_endpoint_id(
return endpoint_data.endpoint.id
async def _get_container_and_endpoint_ids(
call: ServiceCall,
) -> tuple[PortainerConfigEntry, int, str]:
"""Get config entry, endpoint ID and container ID from the container device ID."""
device_reg = dr.async_get(call.hass)
device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID])
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
config_entry: PortainerConfigEntry | None = None
for loaded_entry in call.hass.config_entries.async_loaded_entries(DOMAIN):
if loaded_entry.entry_id in device.config_entries:
config_entry = loaded_entry
break
if config_entry is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
coordinator = config_entry.runtime_data
for data in coordinator.data.values():
for container_name, container_data in data.containers.items():
if (
DOMAIN,
f"{config_entry.entry_id}_{data.endpoint.id}_{container_name}",
) in device.identifiers:
return config_entry, data.endpoint.id, container_data.container.id
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
async def prune_images(call: ServiceCall) -> None:
"""Prune unused images in Portainer, with more controls."""
config_entry = await _extract_config_entry(call)
@@ -157,40 +104,6 @@ async def prune_images(call: ServiceCall) -> None:
) from err
async def recreate_container(call: ServiceCall) -> None:
"""Recreate a container in Portainer, with more controls."""
config_entry, endpoint_id, container_id = await _get_container_and_endpoint_ids(
call
)
coordinator = config_entry.runtime_data
timeout: timedelta | None = call.data.get(ATTR_TIMEOUT)
try:
await coordinator.portainer.container_recreate(
endpoint_id=endpoint_id,
container_id=container_id,
**({"timeout": timeout} if timeout is not None else {}),
pull_image=call.data.get(ATTR_PULL_IMAGE, False),
)
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth_no_details",
) from err
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_no_details",
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect_no_details",
) from err
await coordinator.async_request_refresh()
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
@@ -200,10 +113,3 @@ async def async_setup_services(hass: HomeAssistant) -> None:
prune_images,
SERVICE_PRUNE_IMAGES_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
recreate_container,
SERVICE_RECREATE_CONTAINER_SCHEMA,
)
@@ -16,20 +16,3 @@ prune_images:
required: false
selector:
boolean: {}
recreate_container:
fields:
container_device_id:
required: true
selector:
device:
integration: portainer
model: Container
timeout:
required: false
selector:
duration:
pull_image:
required: false
selector:
boolean:
@@ -235,24 +235,6 @@
}
},
"name": "Prune unused images"
},
"recreate_container": {
"description": "Recreates a container on a Portainer endpoint. This is more disruptive than a restart as the container will be stopped, removed, and then re-created with the same configuration. Use with caution.",
"fields": {
"container_device_id": {
"description": "The container to recreate.",
"name": "Container"
},
"pull_image": {
"description": "Whether to pull the image before recreating the container. This can be used to update the container to the latest version of the image.",
"name": "Pull image"
},
"timeout": {
"description": "The time to wait for the container to stop before killing it. If not provided, a default of 5 minutes will be used.",
"name": "Timeout"
}
},
"name": "Recreate container"
}
},
"system_health": {
+16 -16
View File
@@ -29,29 +29,29 @@
}
},
"sensor": {
"flow_sensor_clicks_cubic_meter": {
"default": "mdi:water-pump"
"translation_key_0": {
"default": "mdi:abc"
},
"flow_sensor_consumed_liters": {
"default": "mdi:water-pump"
"translation_key_1": {
"default": "mdi:abc"
},
"flow_sensor_leak_clicks": {
"default": "mdi:pipe-leak"
"translation_key_2": {
"default": "mdi:abc"
},
"flow_sensor_leak_volume": {
"default": "mdi:pipe-leak"
"translation_key_3": {
"default": "mdi:abc"
},
"flow_sensor_start_index": {
"default": "mdi:water-pump"
"translation_key_4": {
"default": "mdi:abc"
},
"flow_sensor_watering_clicks": {
"default": "mdi:water-pump"
"translation_key_5": {
"default": "mdi:abc"
},
"last_leak_detected": {
"default": "mdi:pipe-leak"
"translation_key_6": {
"default": "mdi:abc"
},
"rain_sensor_rain_start": {
"default": "mdi:weather-pouring"
"translation_key_7": {
"default": "mdi:abc"
}
},
"switch": {
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.20.0"]
"requirements": ["reolink-aio==0.19.1"]
}
@@ -23,7 +23,7 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_REGION, CONF_USERNAME
from homeassistant.const import CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -38,6 +38,7 @@ from . import RoborockConfigEntry
from .const import (
CONF_BASE_URL,
CONF_ENTRY_CODE,
CONF_REGION,
CONF_SHOW_BACKGROUND,
CONF_SHOW_ROOMS,
CONF_SHOW_WALLS,
@@ -13,6 +13,8 @@ CONF_USER_DATA = "user_data"
CONF_SHOW_BACKGROUND = "show_background"
CONF_SHOW_WALLS = "show_walls"
CONF_SHOW_ROOMS = "show_rooms"
# pylint: disable-next=home-assistant-duplicate-const
CONF_REGION = "region"
REGION_OPTIONS = ["auto", "us", "eu", "ru", "cn"]
# Option Flow steps
+2 -1
View File
@@ -15,7 +15,6 @@ from homeassistant.components.sensor import (
SensorEntity,
)
from homeassistant.const import (
ATTR_MODEL,
CONF_MAC,
CONF_NAME,
EVENT_HOMEASSISTANT_STOP,
@@ -31,6 +30,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
ATTR_DEVICE = "device"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL = "model"
BLE_TEMP_HANDLE = 0x24
BLE_TEMP_UUID = "0000ff92-0000-1000-8000-00805f9b34fb"
+2 -2
View File
@@ -1,7 +1,5 @@
"""Define constants for the SleepIQ component."""
from homeassistant.const import PRESSURE
DATA_SLEEPIQ = "data_sleepiq"
DOMAIN = "sleepiq"
@@ -13,6 +11,8 @@ FIRMNESS = "firmness"
ICON_EMPTY = "mdi:bed-empty"
ICON_OCCUPIED = "mdi:bed"
IS_IN_BED = "is_in_bed"
# pylint: disable-next=home-assistant-duplicate-const
PRESSURE = "pressure"
SLEEP_NUMBER = "sleep_number"
FOOT_WARMING_TIMER = "foot_warming_timer"
FOOT_WARMER = "foot_warmer"
+2 -1
View File
@@ -11,13 +11,14 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PRESSURE, UnitOfTime
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
HEART_RATE,
HRV,
PRESSURE,
RESPIRATORY_RATE,
SLEEP_DURATION,
SLEEP_NUMBER,
+2 -1
View File
@@ -7,7 +7,6 @@ import smarttub
import voluptuous as vol
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -20,6 +19,8 @@ from .entity import SmartTubOnboardSensorBase
# the desired duration, in hours, of the cycle
ATTR_DURATION = "duration"
ATTR_CYCLE_LAST_UPDATED = "cycle_last_updated"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODE = "mode"
# the hour of the day at which to start the cycle (0-23)
ATTR_START_HOUR = "start_hour"
+4
View File
@@ -38,8 +38,12 @@ PLATFORMS = [
Platform.SENSOR,
]
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_LOCK = "lock"
SERVICE_REMOTE_START = "remote_start"
SERVICE_REMOTE_STOP = "remote_stop"
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_UNLOCK = "unlock"
SERVICE_UNLOCK_SPECIFIC_DOOR = "unlock_specific_door"
ATTR_DOOR = "door"
@@ -4,10 +4,9 @@ import logging
from subarulink.exceptions import SubaruException
from homeassistant.const import SERVICE_UNLOCK
from homeassistant.exceptions import HomeAssistantError
from .const import SERVICE_REMOTE_START, VEHICLE_NAME, VEHICLE_VIN
from .const import SERVICE_REMOTE_START, SERVICE_UNLOCK, VEHICLE_NAME, VEHICLE_VIN
_LOGGER = logging.getLogger(__name__)
@@ -7,13 +7,14 @@ from surepy.enums import Location
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
import voluptuous as vol
from homeassistant.const import ATTR_LOCATION, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_FLAP_ID,
ATTR_LOCATION,
ATTR_LOCK_STATE,
ATTR_PET_NAME,
DOMAIN,
@@ -18,5 +18,7 @@ SURE_BATT_VOLTAGE_DIFF = SURE_BATT_VOLTAGE_FULL - SURE_BATT_VOLTAGE_LOW
SERVICE_SET_LOCK_STATE = "set_lock_state"
SERVICE_SET_PET_LOCATION = "set_pet_location"
ATTR_FLAP_ID = "flap_id"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCATION = "location"
ATTR_LOCK_STATE = "lock_state"
ATTR_PET_NAME = "pet_name"
@@ -8,7 +8,7 @@ from surepy.enums import EntityType, Location, LockState
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LOCATION, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -16,6 +16,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
ATTR_FLAP_ID,
ATTR_LOCATION,
ATTR_LOCK_STATE,
ATTR_PET_NAME,
DOMAIN,
@@ -1,7 +1,9 @@
"""The System Bridge integration."""
import asyncio
from dataclasses import asdict
import logging
from typing import Any
from systembridgeconnector.exceptions import (
AuthenticationException,
@@ -9,34 +11,71 @@ from systembridgeconnector.exceptions import (
ConnectionErrorException,
DataMissingException,
)
from systembridgeconnector.models.keyboard_key import KeyboardKey
from systembridgeconnector.models.keyboard_text import KeyboardText
from systembridgeconnector.models.modules.processes import Process
from systembridgeconnector.models.open_path import OpenPath
from systembridgeconnector.models.open_url import OpenUrl
from systembridgeconnector.version import Version
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import (
CONF_API_KEY,
CONF_COMMAND,
CONF_ENTITY_ID,
CONF_HOST,
CONF_ID,
CONF_NAME,
CONF_PATH,
CONF_PORT,
CONF_TOKEN,
CONF_URL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
discovery,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from .config_flow import SystemBridgeConfigFlow
from .const import DATA_WAIT_TIMEOUT, DOMAIN, MODULES
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
from .services import async_setup_services
def _get_coordinator(
hass: HomeAssistant, entry_id: str
) -> SystemBridgeDataUpdateCoordinator:
"""Return the coordinator for a config entry id."""
entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry(
entry_id
)
if entry is None or entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device": entry_id},
)
return entry.runtime_data
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.MEDIA_PLAYER,
@@ -45,12 +84,26 @@ PLATFORMS = [
Platform.UPDATE,
]
CONF_BRIDGE = "bridge"
CONF_KEY = "key"
CONF_TEXT = "text"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the System Bridge services."""
SERVICE_GET_PROCESS_BY_ID = "get_process_by_id"
SERVICE_GET_PROCESSES_BY_NAME = "get_processes_by_name"
SERVICE_OPEN_PATH = "open_path"
SERVICE_POWER_COMMAND = "power_command"
SERVICE_OPEN_URL = "open_url"
SERVICE_SEND_KEYPRESS = "send_keypress"
SERVICE_SEND_TEXT = "send_text"
async_setup_services(hass)
return True
POWER_COMMAND_MAP = {
"hibernate": "power_hibernate",
"lock": "power_lock",
"logout": "power_logout",
"restart": "power_restart",
"shutdown": "power_shutdown",
"sleep": "power_sleep",
}
async def async_setup_entry(
@@ -178,6 +231,219 @@ async def async_setup_entry(
)
)
if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL):
return True
def valid_device(device: str) -> str:
"""Check device is valid."""
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device)
if device_entry is not None:
try:
return next(
entry.entry_id
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.entry_id in device_entry.config_entries
)
except StopIteration as exception:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device": device},
) from exception
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device": device},
)
async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse:
"""Handle the get process by id service call."""
_LOGGER.debug("Get process by id: %s", service_call.data)
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
processes: list[Process] = coordinator.data.processes
# Find process.id from list, raise ServiceValidationError if not found
try:
return asdict(
next(
process
for process in processes
if process.id == service_call.data[CONF_ID]
)
)
except StopIteration as exception:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="process_not_found",
translation_placeholders={"id": service_call.data[CONF_ID]},
) from exception
async def handle_get_processes_by_name(
service_call: ServiceCall,
) -> ServiceResponse:
"""Handle the get process by name service call."""
_LOGGER.debug("Get process by name: %s", service_call.data)
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
# Find processes from list
items: list[dict[str, Any]] = [
asdict(process)
for process in coordinator.data.processes
if process.name is not None
and service_call.data[CONF_NAME].lower() in process.name.lower()
]
return {
"count": len(items),
"processes": list(items),
}
async def handle_open_path(service_call: ServiceCall) -> ServiceResponse:
"""Handle the open path service call."""
_LOGGER.debug("Open path: %s", service_call.data)
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.open_path(
OpenPath(path=service_call.data[CONF_PATH])
)
return asdict(response)
async def handle_power_command(service_call: ServiceCall) -> ServiceResponse:
"""Handle the power command service call."""
_LOGGER.debug("Power command: %s", service_call.data)
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
response = await getattr(
coordinator.websocket_client,
POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]],
)()
return asdict(response)
async def handle_open_url(service_call: ServiceCall) -> ServiceResponse:
"""Handle the open url service call."""
_LOGGER.debug("Open URL: %s", service_call.data)
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.open_url(
OpenUrl(url=service_call.data[CONF_URL])
)
return asdict(response)
async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse:
"""Handle the send_keypress service call."""
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.keyboard_keypress(
KeyboardKey(key=service_call.data[CONF_KEY])
)
return asdict(response)
async def handle_send_text(service_call: ServiceCall) -> ServiceResponse:
"""Handle the send_keypress service call."""
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.keyboard_text(
KeyboardText(text=service_call.data[CONF_TEXT])
)
return asdict(response)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_GET_PROCESS_BY_ID,
handle_get_process_by_id,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_ID): cv.positive_int,
},
),
supports_response=SupportsResponse.ONLY,
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_GET_PROCESSES_BY_NAME,
handle_get_processes_by_name,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_NAME): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_OPEN_PATH,
handle_open_path,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_PATH): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_POWER_COMMAND,
handle_power_command,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP),
},
),
supports_response=SupportsResponse.ONLY,
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_OPEN_URL,
handle_open_url,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_URL): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_SEND_KEYPRESS,
handle_send_keypress,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_KEY): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
description_placeholders={
"syntax_keys_documentation_url": "http://robotjs.io/docs/syntax#keys"
},
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_SEND_TEXT,
handle_send_text,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_TEXT): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
# Reload entry when its updated.
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
@@ -188,7 +454,9 @@ async def async_unload_entry(
hass: HomeAssistant, entry: SystemBridgeConfigEntry
) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
)
if unload_ok:
coordinator = entry.runtime_data
@@ -17,51 +17,15 @@
"boot_time": {
"default": "mdi:av-timer"
},
"cpu_power_core": {
"default": "mdi:chip"
},
"cpu_power_package": {
"default": "mdi:chip"
},
"cpu_speed": {
"default": "mdi:speedometer"
},
"display_refresh_rate": {
"default": "mdi:monitor"
},
"display_resolution_x": {
"default": "mdi:monitor"
},
"display_resolution_y": {
"default": "mdi:monitor"
},
"displays_connected": {
"default": "mdi:monitor"
},
"gpu_core_clock_speed": {
"default": "mdi:speedometer"
},
"gpu_fan_speed": {
"default": "mdi:fan"
},
"gpu_memory_clock_speed": {
"default": "mdi:speedometer"
},
"gpu_memory_free": {
"default": "mdi:memory"
},
"gpu_memory_used": {
"default": "mdi:memory"
},
"gpu_memory_used_percentage": {
"default": "mdi:memory"
},
"gpu_power_usage": {
"default": "mdi:lightning-bolt"
},
"gpu_usage_percentage": {
"default": "mdi:percent"
},
"kernel": {
"default": "mdi:devices"
},
@@ -74,9 +38,6 @@
"memory_used": {
"default": "mdi:memory"
},
"memory_used_percentage": {
"default": "mdi:memory"
},
"os": {
"default": "mdi:devices"
},
@@ -86,12 +47,6 @@
"processes": {
"default": "mdi:counter"
},
"processes_load_cpu": {
"default": "mdi:percent"
},
"space_used": {
"default": "mdi:harddisk"
},
"version": {
"default": "mdi:counter"
},
@@ -27,7 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.typing import UNDEFINED, StateType
from homeassistant.util import dt as dt_util
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
@@ -284,10 +284,10 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
),
SystemBridgeSensorEntityDescription(
key="memory_used_percentage",
translation_key="memory_used_percentage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
icon="mdi:memory",
value=lambda data: data.memory.virtual.percent,
),
SystemBridgeSensorEntityDescription(
@@ -380,11 +380,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"filesystem_{partition.mount_point.replace(':', '')}",
translation_key="space_used",
translation_placeholders={"partition": partition.mount_point},
name=f"{partition.mount_point} space used",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
icon="mdi:harddisk",
value=(
lambda data, dk=index_device, pk=index_partition: (
partition_usage(data, dk, pk)
@@ -427,10 +427,10 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"display_{display.id}_resolution_x",
translation_key="display_resolution_x",
translation_placeholders={"display_id": display.id},
name=f"Display {display.id} resolution x",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PIXELS,
icon="mdi:monitor",
value=lambda data, k=index: display_resolution_horizontal(
data, k
),
@@ -441,10 +441,10 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"display_{display.id}_resolution_y",
translation_key="display_resolution_y",
translation_placeholders={"display_id": display.id},
name=f"Display {display.id} resolution y",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PIXELS,
icon="mdi:monitor",
value=lambda data, k=index: display_resolution_vertical(
data, k
),
@@ -455,12 +455,12 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"display_{display.id}_refresh_rate",
translation_key="display_refresh_rate",
translation_placeholders={"display_id": display.id},
name=f"Display {display.id} refresh rate",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=0,
icon="mdi:monitor",
value=lambda data, k=index: display_refresh_rate(data, k),
),
entry.data[CONF_PORT],
@@ -474,13 +474,13 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_core_clock_speed",
translation_key="gpu_core_clock_speed",
translation_placeholders={"gpu_name": gpu.name},
name=f"{gpu.name} clock speed",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=0,
icon="mdi:speedometer",
value=lambda data, k=index: gpu_core_clock_speed(data, k),
),
entry.data[CONF_PORT],
@@ -489,13 +489,13 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_memory_clock_speed",
translation_key="gpu_memory_clock_speed",
translation_placeholders={"gpu_name": gpu.name},
name=f"{gpu.name} memory clock speed",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=0,
icon="mdi:speedometer",
value=lambda data, k=index: gpu_memory_clock_speed(data, k),
),
entry.data[CONF_PORT],
@@ -504,12 +504,12 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_memory_free",
translation_key="gpu_memory_free",
translation_placeholders={"gpu_name": gpu.name},
name=f"{gpu.name} memory free",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
suggested_display_precision=0,
icon="mdi:memory",
value=lambda data, k=index: gpu_memory_free(data, k),
),
entry.data[CONF_PORT],
@@ -518,11 +518,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_memory_used_percentage",
translation_key="gpu_memory_used_percentage",
translation_placeholders={"gpu_name": gpu.name},
name=f"{gpu.name} memory used %",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
icon="mdi:memory",
value=lambda data, k=index: gpu_memory_used_percentage(data, k),
),
entry.data[CONF_PORT],
@@ -531,13 +531,13 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_memory_used",
translation_key="gpu_memory_used",
translation_placeholders={"gpu_name": gpu.name},
name=f"{gpu.name} memory used",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
suggested_display_precision=0,
icon="mdi:memory",
value=lambda data, k=index: gpu_memory_used(data, k),
),
entry.data[CONF_PORT],
@@ -546,11 +546,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_fan_speed",
translation_key="gpu_fan_speed",
translation_placeholders={"gpu_name": gpu.name},
name=f"{gpu.name} fan speed",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
icon="mdi:fan",
value=lambda data, k=index: gpu_fan_speed(data, k),
),
entry.data[CONF_PORT],
@@ -559,8 +559,7 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_power_usage",
translation_key="gpu_power_usage",
translation_placeholders={"gpu_name": gpu.name},
name=f"{gpu.name} power usage",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -572,8 +571,7 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_temperature",
translation_key="gpu_temperature",
translation_placeholders={"gpu_name": gpu.name},
name=f"{gpu.name} temperature",
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
@@ -587,11 +585,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_usage_percentage",
translation_key="gpu_usage_percentage",
translation_placeholders={"gpu_name": gpu.name},
name=f"{gpu.name} usage %",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
icon="mdi:percent",
value=lambda data, k=index: gpu_usage_percentage(data, k),
),
entry.data[CONF_PORT],
@@ -607,11 +605,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"processes_load_cpu_{cpu.id}",
translation_key="processes_load_cpu",
translation_placeholders={"cpu_id": str(cpu.id)},
name=f"Load CPU {cpu.id}",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:percent",
suggested_display_precision=2,
value=lambda data, k=cpu.id: cpu_usage_per_cpu(data, k),
),
@@ -621,11 +619,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"cpu_power_core_{cpu.id}",
translation_key="cpu_power_core",
translation_placeholders={"cpu_id": str(cpu.id)},
name=f"CPU Core {cpu.id} Power",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:chip",
suggested_display_precision=2,
value=lambda data, k=cpu.id: cpu_power_per_cpu(data, k),
),
@@ -655,6 +653,8 @@ class SystemBridgeSensor(SystemBridgeEntity, SensorEntity):
description.key,
)
self.entity_description = description
if description.name is not UNDEFINED:
self._attr_has_entity_name = False
@property
def native_value(self) -> StateType:
@@ -1,269 +0,0 @@
"""Service registration for System Bridge integration."""
from dataclasses import asdict
import logging
from typing import Any
from systembridgeconnector.models.keyboard_key import KeyboardKey
from systembridgeconnector.models.keyboard_text import KeyboardText
from systembridgeconnector.models.modules.processes import Process
from systembridgeconnector.models.open_path import OpenPath
from systembridgeconnector.models.open_url import OpenUrl
import voluptuous as vol
from homeassistant.const import CONF_COMMAND, CONF_ID, CONF_NAME, CONF_PATH, CONF_URL
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
service,
)
from .const import DOMAIN
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
CONF_BRIDGE = "bridge"
CONF_KEY = "key"
CONF_TEXT = "text"
POWER_COMMAND_MAP = {
"hibernate": "power_hibernate",
"lock": "power_lock",
"logout": "power_logout",
"restart": "power_restart",
"shutdown": "power_shutdown",
"sleep": "power_sleep",
}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for System Bridge integration."""
hass.services.async_register(
DOMAIN,
"get_process_by_id",
handle_get_process_by_id,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_ID): cv.positive_int,
},
),
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"get_processes_by_name",
handle_get_processes_by_name,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_NAME): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"open_path",
handle_open_path,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_PATH): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"power_command",
handle_power_command,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP),
},
),
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"open_url",
handle_open_url,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_URL): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"send_keypress",
handle_send_keypress,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_KEY): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
description_placeholders={
"syntax_keys_documentation_url": "https://robotjs.dev/docs/syntax#keys"
},
)
hass.services.async_register(
DOMAIN,
"send_text",
handle_send_text,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_TEXT): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
def _get_coordinator(
hass: HomeAssistant, device_id: str
) -> SystemBridgeDataUpdateCoordinator:
"""Return the coordinator for a device id."""
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
if device_entry is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device": device_id},
)
try:
entry_id = next(
entry.entry_id
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.entry_id in device_entry.config_entries
)
except StopIteration as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device": device_id},
) from e
entry: SystemBridgeConfigEntry = service.async_get_config_entry(
hass, DOMAIN, entry_id
)
return entry.runtime_data
async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse:
"""Handle the get process by id service call."""
_LOGGER.debug("Get process by id: %s", service_call.data)
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
processes: list[Process] = coordinator.data.processes
# Find process.id from list, raise ServiceValidationError if not found
try:
return asdict(
next(
process
for process in processes
if process.id == service_call.data[CONF_ID]
)
)
except StopIteration as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="process_not_found",
translation_placeholders={"id": service_call.data[CONF_ID]},
) from e
async def handle_get_processes_by_name(
service_call: ServiceCall,
) -> ServiceResponse:
"""Handle the get process by name service call."""
_LOGGER.debug("Get process by name: %s", service_call.data)
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
# Find processes from list
items: list[dict[str, Any]] = [
asdict(process)
for process in coordinator.data.processes
if process.name is not None
and service_call.data[CONF_NAME].lower() in process.name.lower()
]
return {
"count": len(items),
"processes": list(items),
}
async def handle_open_path(service_call: ServiceCall) -> ServiceResponse:
"""Handle the open path service call."""
_LOGGER.debug("Open path: %s", service_call.data)
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.open_path(
OpenPath(path=service_call.data[CONF_PATH])
)
return asdict(response)
async def handle_power_command(service_call: ServiceCall) -> ServiceResponse:
"""Handle the power command service call."""
_LOGGER.debug("Power command: %s", service_call.data)
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
response = await getattr(
coordinator.websocket_client,
POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]],
)()
return asdict(response)
async def handle_open_url(service_call: ServiceCall) -> ServiceResponse:
"""Handle the open url service call."""
_LOGGER.debug("Open URL: %s", service_call.data)
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.open_url(
OpenUrl(url=service_call.data[CONF_URL])
)
return asdict(response)
async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse:
"""Handle the send_keypress service call."""
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.keyboard_keypress(
KeyboardKey(key=service_call.data[CONF_KEY])
)
return asdict(response)
async def handle_send_text(service_call: ServiceCall) -> ServiceResponse:
"""Handle the send_text service call."""
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.keyboard_text(
KeyboardText(text=service_call.data[CONF_TEXT])
)
return asdict(response)
@@ -89,4 +89,3 @@ power_command:
- "restart"
- "shutdown"
- "sleep"
translation_key: "power_command"
@@ -54,9 +54,6 @@
"boot_time": {
"name": "Boot time"
},
"cpu_power_core": {
"name": "CPU core {cpu_id} power"
},
"cpu_power_package": {
"name": "CPU package power"
},
@@ -69,45 +66,9 @@
"cpu_voltage": {
"name": "CPU voltage"
},
"display_refresh_rate": {
"name": "Display {display_id} refresh rate"
},
"display_resolution_x": {
"name": "Display {display_id} resolution x"
},
"display_resolution_y": {
"name": "Display {display_id} resolution y"
},
"displays_connected": {
"name": "Displays connected"
},
"gpu_core_clock_speed": {
"name": "{gpu_name} clock speed"
},
"gpu_fan_speed": {
"name": "{gpu_name} fan speed"
},
"gpu_memory_clock_speed": {
"name": "{gpu_name} memory clock speed"
},
"gpu_memory_free": {
"name": "{gpu_name} memory free"
},
"gpu_memory_used": {
"name": "{gpu_name} memory used"
},
"gpu_memory_used_percentage": {
"name": "{gpu_name} memory used %"
},
"gpu_power_usage": {
"name": "{gpu_name} power usage"
},
"gpu_temperature": {
"name": "{gpu_name} temperature"
},
"gpu_usage_percentage": {
"name": "{gpu_name} usage %"
},
"kernel": {
"name": "Kernel"
},
@@ -120,9 +81,6 @@
"memory_used": {
"name": "Memory used"
},
"memory_used_percentage": {
"name": "Memory used %"
},
"os": {
"name": "Operating system"
},
@@ -132,12 +90,6 @@
"processes": {
"name": "Processes"
},
"processes_load_cpu": {
"name": "Load CPU {cpu_id}"
},
"space_used": {
"name": "{partition} space used"
},
"version": {
"name": "Version"
},
@@ -178,18 +130,6 @@
"title": "System Bridge upgrade required"
}
},
"selector": {
"power_command": {
"options": {
"hibernate": "Hibernate",
"lock": "Lock",
"logout": "Logout",
"restart": "[%key:common::action::restart%]",
"shutdown": "Shutdown",
"sleep": "Sleep"
}
}
},
"services": {
"get_process_by_id": {
"description": "Gets a process by the ID.",
@@ -32,7 +32,6 @@ class SystemBridgeUpdateEntity(SystemBridgeEntity, UpdateEntity):
_attr_has_entity_name = True
_attr_title = "System Bridge"
_attr_name = None
def __init__(
self,
@@ -45,6 +44,7 @@ class SystemBridgeUpdateEntity(SystemBridgeEntity, UpdateEntity):
api_port,
"update",
)
self._attr_name = coordinator.data.system.hostname
@property
def installed_version(self) -> str | None:
@@ -58,7 +58,7 @@ send_message:
selector:
number:
mode: box
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -101,7 +101,7 @@ send_chat_action:
selector:
number:
mode: box
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -195,7 +195,7 @@ send_photo:
selector:
number:
mode: box
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -287,7 +287,7 @@ send_media_group:
selector:
number:
mode: box
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -372,7 +372,7 @@ send_sticker:
selector:
number:
mode: box
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -466,7 +466,7 @@ send_animation:
selector:
number:
mode: box
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -560,7 +560,7 @@ send_video:
selector:
number:
mode: box
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -645,7 +645,7 @@ send_voice:
selector:
number:
mode: box
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -739,7 +739,7 @@ send_document:
selector:
number:
mode: box
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -804,7 +804,7 @@ send_location:
selector:
number:
mode: box
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -861,7 +861,7 @@ send_poll:
selector:
number:
mode: box
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -913,7 +913,7 @@ edit_message:
["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
selector:
object:
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -991,7 +991,7 @@ edit_message_media:
["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
selector:
object:
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -1028,7 +1028,7 @@ edit_caption:
["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
selector:
object:
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -1061,7 +1061,7 @@ edit_replymarkup:
["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
selector:
object:
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -1108,7 +1108,7 @@ delete_message:
example: "{{ trigger.event.data.message.message_id }}"
selector:
text:
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -1129,7 +1129,7 @@ leave_chat:
filter:
domain: notify
integration: telegram_bot
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -1164,7 +1164,7 @@ set_message_reaction:
required: false
selector:
boolean:
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -1233,7 +1233,7 @@ send_message_draft:
- "markdownv2"
- "plain_text"
translation_key: "parse_mode"
additional_fields:
advanced:
collapsed: true
fields:
config_entry_id:
@@ -367,8 +367,8 @@
},
"name": "Delete message",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
}
}
},
@@ -425,8 +425,8 @@
},
"name": "Edit caption",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
}
}
},
@@ -472,8 +472,8 @@
},
"name": "Edit message",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
}
}
},
@@ -535,8 +535,8 @@
},
"name": "Edit message media",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
},
"url_options": {
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
@@ -569,8 +569,8 @@
},
"name": "Edit reply markup",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
}
}
},
@@ -592,8 +592,8 @@
},
"name": "Leave chat",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
}
}
},
@@ -671,8 +671,8 @@
},
"name": "Send animation",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
},
"url_options": {
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
@@ -705,8 +705,8 @@
},
"name": "Send chat action",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
}
}
},
@@ -784,8 +784,8 @@
},
"name": "Send document",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
},
"url_options": {
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
@@ -842,8 +842,8 @@
},
"name": "Send location",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
}
}
},
@@ -889,8 +889,8 @@
},
"name": "Send media group",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
}
}
},
@@ -952,8 +952,8 @@
},
"name": "Send message",
"sections": {
"additional_fields": {
"name": "Additional options"
"advanced": {
"name": "Advanced"
}
}
},
@@ -991,8 +991,8 @@
},
"name": "Send message draft",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
}
}
},
@@ -1070,8 +1070,8 @@
},
"name": "Send photo",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "Advanced"
},
"url_options": {
"name": "URL options"
@@ -1128,8 +1128,8 @@
},
"name": "Send poll",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
}
}
},
@@ -1203,8 +1203,8 @@
},
"name": "Send sticker",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
},
"url_options": {
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
@@ -1285,8 +1285,8 @@
},
"name": "Send video",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
},
"url_options": {
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
@@ -1363,8 +1363,8 @@
},
"name": "Send voice",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
},
"url_options": {
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
@@ -1401,8 +1401,8 @@
},
"name": "Set message reaction",
"sections": {
"additional_fields": {
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
"advanced": {
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
}
}
}
@@ -497,7 +497,7 @@
"default": "mdi:battery-clock"
},
"forward_collision_warning": {
"default": "mdi:car-emergency",
"default": "mdi:car-crash",
"state": {
"average": "mdi:alert-circle",
"early": "mdi:alert-octagon",
@@ -634,7 +634,7 @@
"default": "mdi:key"
},
"pedal_position": {
"default": "mdi:gauge"
"default": "mdi:pedestal"
},
"powershare_hours_left": {
"default": "mdi:clock-time-eight-outline"
@@ -794,7 +794,7 @@
"service": "mdi:calendar-plus"
},
"add_precondition_schedule": {
"service": "mdi:hvac"
"service": "mdi:hvac-outline"
},
"navigation_gps_request": {
"service": "mdi:crosshairs-gps"
@@ -803,7 +803,7 @@
"service": "mdi:calendar-minus"
},
"remove_precondition_schedule": {
"service": "mdi:hvac-off"
"service": "mdi:hvac-off-outline"
},
"set_scheduled_charging": {
"service": "mdi:timeline-clock-outline"
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["uiprotect"],
"quality_scale": "platinum",
"requirements": ["uiprotect==10.5.0"]
"requirements": ["uiprotect==10.4.1"]
}
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, PLATFORMS
from .const import PLATFORMS
from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator
@@ -15,9 +15,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: UptimeRobotConfigEntry)
"""Set up UptimeRobot from a config entry."""
key: str = entry.data[CONF_API_KEY]
if key.startswith(("ur", "m")):
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="api_key_wrong_type",
"Wrong API key type detected, use the 'main' API key"
)
uptime_robot_api = UptimeRobot(key, async_get_clientsession(hass))
@@ -48,16 +48,11 @@ class UptimeRobotDataUpdateCoordinator(
try:
response = await self.api.async_get_monitors()
except UptimeRobotAuthenticationException as exception:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="api_authentication_exception",
) from exception
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed(exception) from exception
except UptimeRobotException as exception:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_generic_exception",
translation_placeholders={"error": "Generic UptimeRobot exception"},
) from exception
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(exception) from exception
if TYPE_CHECKING:
assert isinstance(response.data, list)
@@ -57,16 +57,7 @@
}
},
"exceptions": {
"api_authentication_exception": {
"message": "API authentication failed, please check your API key"
},
"api_generic_exception": {
"message": "API error: {error}"
},
"api_key_wrong_type": {
"message": "Wrong API key type detected, use the 'main' API key"
},
"api_switch_exception": {
"api_exception": {
"message": "Could not turn on/off monitoring: {error}"
}
}
@@ -33,7 +33,7 @@ def uptimerobot_api_call[_T: UptimeRobotEntity, **_P](
except UptimeRobotException as exception:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_switch_exception",
translation_key="api_exception",
translation_placeholders={"error": "Generic UptimeRobot exception"},
) from exception
@@ -68,7 +68,7 @@
"state": {
"lightning": "mdi:weather-lightning-rainy",
"rain": "mdi:weather-rainy",
"rain_snow": "mdi:weather-snowy-rainy",
"rain_snow": "mdi:weather-snoy-rainy",
"snow": "mdi:weather-snowy"
}
},
+2 -4
View File
@@ -46,10 +46,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b
try:
await client.connect()
except WebOsTvPairError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed(err) from err
# If pairing request accepted there will be no error
# Update the stored key without triggering reauth
@@ -6,7 +6,6 @@ from homeassistant.components.device_automation import (
DEVICE_TRIGGER_BASE_SCHEMA,
InvalidDeviceAutomationConfig,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -14,7 +13,10 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from . import DOMAIN, trigger
from .helpers import async_get_device_entry_by_device_id
from .helpers import (
async_get_client_by_device_entry,
async_get_device_entry_by_device_id,
)
from .triggers.turn_on import (
PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE,
async_get_turn_on_trigger,
@@ -38,31 +40,10 @@ async def async_validate_trigger_config(
device_id = config[CONF_DEVICE_ID]
try:
device = async_get_device_entry_by_device_id(hass, device_id)
async_get_client_by_device_entry(hass, device)
except ValueError as err:
raise InvalidDeviceAutomationConfig(
translation_domain=DOMAIN,
translation_key="device_not_valid",
translation_placeholders={"device_id": device_id},
) from err
for config_entry_id in device.config_entries:
if (
entry := hass.config_entries.async_get_entry(config_entry_id)
) and entry.domain == DOMAIN:
if entry.state is ConfigEntryState.LOADED:
break
raise InvalidDeviceAutomationConfig(
translation_domain=DOMAIN,
translation_key="device_config_entry_not_loaded",
translation_placeholders={"device_id": device.id},
)
else:
raise InvalidDeviceAutomationConfig(
translation_domain=DOMAIN,
translation_key="device_not_valid",
translation_placeholders={"device_id": device.id},
)
# pylint: disable-next=home-assistant-exception-not-translated
raise InvalidDeviceAutomationConfig(err) from err
return config
+26 -1
View File
@@ -4,7 +4,7 @@ import logging
from aiowebostv import WebOsClient, WebOsTvState
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -56,6 +56,31 @@ def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> s
return entity_entry.device_id
@callback
def async_get_client_by_device_entry(
hass: HomeAssistant, device: DeviceEntry
) -> WebOsClient:
"""Get WebOsClient from Device Registry by device entry.
Raises ValueError if client is not found.
"""
for config_entry_id in device.config_entries:
entry: WebOsTvConfigEntry | None = hass.config_entries.async_get_entry(
config_entry_id
)
if entry and entry.domain == DOMAIN:
if entry.state is ConfigEntryState.LOADED:
return entry.runtime_data
raise ValueError(
f"Device {device.id} is not from a loaded {DOMAIN} config entry"
)
raise ValueError(
f"Device {device.id} is not from an existing {DOMAIN} config entry"
)
def get_sources(tv_state: WebOsTvState) -> list[str]:
"""Construct sources list."""
sources = []
@@ -46,18 +46,9 @@
}
},
"exceptions": {
"auth_failed": {
"message": "Pairing failed, make sure to accept the pairing request on your TV."
},
"communication_error": {
"message": "Communication error while calling {func} for device {name}: {error}"
},
"device_config_entry_not_loaded": {
"message": "The LG webOS TV integration for device {device_id} is not loaded."
},
"device_not_valid": {
"message": "Device {device_id} is not a valid LG webOS TV device."
},
"device_off": {
"message": "Error calling {func} for device {name}: Device is off and cannot be controlled."
},
@@ -28,7 +28,7 @@ from miio.integrations.humidifier.zhimi.airhumidifier_miot import (
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import ATTR_MODE, CONF_DEVICE, CONF_MODEL, EntityCategory
from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -65,6 +65,8 @@ from .typing import XiaomiMiioConfigEntry
ATTR_DISPLAY_ORIENTATION = "display_orientation"
ATTR_LED_BRIGHTNESS = "led_brightness"
ATTR_PTC_LEVEL = "ptc_level"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODE = "mode"
_LOGGER = logging.getLogger(__name__)
@@ -25,7 +25,6 @@ from homeassistant.components.switch import (
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_MODE,
ATTR_MODEL,
ATTR_TEMPERATURE,
CONF_DEVICE,
CONF_HOST,
@@ -150,6 +149,8 @@ ATTR_LED = "led"
ATTR_IONIZER = "ionizer"
ATTR_ANION = "anion"
ATTR_LOAD_POWER = "load_power"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL = "model"
ATTR_POWER = "power"
ATTR_POWER_MODE = "power_mode"
ATTR_POWER_PRICE = "power_price"
@@ -21,4 +21,4 @@ CONF_INSTANCE_ID = "instance_id"
# Polling interval (seconds)
DEFAULT_SCAN_INTERVAL = 1800
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.LOCK, Platform.SWITCH]
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SWITCH]
@@ -1,47 +0,0 @@
"""Lock platform for Xthings Cloud."""
from typing import Any
from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import XthingsCloudConfigEntry
from .entity import XthingsCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: XthingsCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lock platform."""
coordinator = entry.runtime_data
entities = [
XthingsCloudLock(coordinator, device_id, device_data)
for device_id, device_data in coordinator.data.items()
if device_data["type"] == "lock"
]
async_add_entities(entities)
class XthingsCloudLock(XthingsCloudEntity, LockEntity):
"""Xthings Cloud lock entity."""
@property
def is_locked(self) -> bool | None:
"""Return true if lock is locked."""
return self.device_data["status"].get("locked")
@property
def is_jammed(self) -> bool | None:
"""Return true if lock is jammed."""
return self.device_data["status"].get("jammed")
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
await self.coordinator.client.async_lock_lock(self._device_id)
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device."""
await self.coordinator.client.async_lock_unlock(self._device_id)
+4 -4
View File
@@ -1,7 +1,7 @@
# Automatically generated by gen_requirements_all.py, do not edit
aiodhcpwatcher==1.2.1
aiodiscover==3.2.3
aiodiscover==3.2.0
aiodns==4.0.4
aiogithubapi==26.0.0
aiohttp-asyncmdnsresolver==0.1.1
@@ -30,12 +30,12 @@ certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==48.0.0
dbus-fast==5.0.3
dbus-fast==5.0.0
file-read-backwards==2.0.0
fnv-hash-fast==2.0.2
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.2.0
habluetooth==6.1.0
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
@@ -133,7 +133,7 @@ multidict>=6.0.2
Brotli>=1.2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.13.4
pydantic==2.13.2
# Required for Python 3.14.0 compatibility (#119223).
mashumaro>=3.17.0

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