mirror of
https://github.com/home-assistant/core.git
synced 2026-05-28 03:33:19 +02:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e4f0e143d | |||
| 218c3ffd11 | |||
| 3372bf45ec | |||
| 9744388a4e | |||
| 75c52a382e | |||
| f8a65a7c6f | |||
| b2d934fae1 | |||
| eb72a72182 | |||
| a4b9de867c | |||
| 3a4e697414 | |||
| 00010a7508 | |||
| c5e4e97fa9 | |||
| 3f6e323b48 | |||
| b9639ec9f6 | |||
| 31bce13d16 | |||
| 3523a26abd | |||
| a6fcc9f3ff | |||
| efe0000fbe | |||
| 98a7cc66ef | |||
| 7feaf71b9e | |||
| 00a0fae7bc | |||
| 0c816c22e0 | |||
| 42f277716d | |||
| 6669b0de25 | |||
| 50fca42624 | |||
| deecb4ee9c | |||
| 762f07f450 | |||
| e02ea041b7 | |||
| 7912afb765 | |||
| 7adaa09333 | |||
| c5e7ed9aba | |||
| 68b8667998 | |||
| f643dd98e5 | |||
| dcec29dbbf | |||
| 1daff77591 | |||
| 7e3fc18c8c | |||
| b6cc5499aa | |||
| 11920b82fe |
@@ -24,6 +24,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.13
|
||||
rev: v0.15.14
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -33,6 +33,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -2054,8 +2054,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/yamaha_musiccast/ @vigonotion @micha91
|
||||
/homeassistant/components/yandex_transport/ @rishatik92 @devbis
|
||||
/tests/components/yandex_transport/ @rishatik92 @devbis
|
||||
/homeassistant/components/yardian/ @h3l1o5
|
||||
/tests/components/yardian/ @h3l1o5
|
||||
/homeassistant/components/yardian/ @aeon-matrix
|
||||
/tests/components/yardian/ @aeon-matrix
|
||||
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||
/homeassistant/components/yeelightsunflower/ @lindsaymarkward
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -51,7 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except ValueError as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_endpoint_url",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
|
||||
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
|
||||
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
|
||||
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
|
||||
"invalid_endpoint_url": "[%key:component::aws_s3::exceptions::invalid_endpoint_url::message%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
@@ -48,6 +48,9 @@
|
||||
},
|
||||
"invalid_credentials": {
|
||||
"message": "Bucket cannot be accessed using provided combination of access key ID and secret access key."
|
||||
},
|
||||
"invalid_endpoint_url": {
|
||||
"message": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.3.0",
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.14",
|
||||
"habluetooth==6.7.4"
|
||||
"dbus-fast==5.0.15",
|
||||
"habluetooth==6.7.9"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||
EntityNumericalStateChangedTriggerBase,
|
||||
EntityNumericalStateChangedTriggerWithUnitBase,
|
||||
EntityNumericalStateCrossedThresholdTriggerBase,
|
||||
@@ -26,7 +26,7 @@ from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
CONF_HVAC_MODE = "hvac_mode"
|
||||
|
||||
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
|
||||
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_HVAC_MODE): vol.All(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -175,7 +175,6 @@ class ConfigManagerFlowIndexView(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("handler"): vol.Any(str, list),
|
||||
vol.Optional("show_advanced_options", default=False): cv.boolean,
|
||||
vol.Optional("entry_id"): cv.string,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -302,7 +301,6 @@ class SubentryManagerFlowIndexView(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
|
||||
vol.Optional("show_advanced_options", default=False): cv.boolean,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -22,6 +22,7 @@ from .const import ( # noqa: F401
|
||||
ATTR_LOCATION_NAME,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONF_CONSIDER_HOME,
|
||||
CONF_NEW_DEVICE_DEFAULTS,
|
||||
CONF_SCAN_INTERVAL,
|
||||
|
||||
@@ -36,6 +36,8 @@ DEFAULT_CONSIDER_HOME: Final = timedelta(seconds=180)
|
||||
|
||||
CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults"
|
||||
|
||||
CONF_ASSOCIATED_ZONE: Final = "associated_zone"
|
||||
|
||||
ATTR_ATTRIBUTES: Final = "attributes"
|
||||
ATTR_BATTERY: Final = "battery"
|
||||
ATTR_DEV_ID: Final = "dev_id"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Provide functionality to keep track of devices."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, final
|
||||
from typing import TYPE_CHECKING, Any, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
@@ -16,8 +16,19 @@ from homeassistant.const import (
|
||||
STATE_NOT_HOME,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, State, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import (
|
||||
DeviceInfo,
|
||||
EventDeviceRegistryUpdatedData,
|
||||
@@ -25,6 +36,7 @@ from homeassistant.helpers.device_registry import (
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
@@ -33,6 +45,7 @@ from .const import (
|
||||
ATTR_IP,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONNECTED_DEVICE_REGISTERED,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
@@ -221,8 +234,8 @@ class TrackerEntity(
|
||||
"""Return the entity_id of zones the device is currently in.
|
||||
|
||||
The list may be in any order; the base class sorts it by zone radius
|
||||
and discards zones which do not exist. Ignored if latitude and
|
||||
longitude are both set.
|
||||
and discards zones which do not exist. Takes precedence over latitude
|
||||
and longitude when set (including when set to an empty list).
|
||||
"""
|
||||
return self._attr_in_zones
|
||||
|
||||
@@ -252,11 +265,7 @@ class TrackerEntity(
|
||||
@callback
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Calculate active zones."""
|
||||
if self.available and self.latitude is not None and self.longitude is not None:
|
||||
self.__active_zone, self.__in_zones = zone.async_in_zones(
|
||||
self.hass, self.latitude, self.longitude, self.location_accuracy
|
||||
)
|
||||
elif (zones := self.in_zones) is not None:
|
||||
if (zones := self.in_zones) is not None:
|
||||
zone_states = sorted(
|
||||
(
|
||||
zone_state
|
||||
@@ -270,6 +279,12 @@ class TrackerEntity(
|
||||
None,
|
||||
)
|
||||
self.__in_zones = [z.entity_id for z in zone_states]
|
||||
elif (
|
||||
self.available and self.latitude is not None and self.longitude is not None
|
||||
):
|
||||
self.__active_zone, self.__in_zones = zone.async_in_zones(
|
||||
self.hass, self.latitude, self.longitude, self.location_accuracy
|
||||
)
|
||||
else:
|
||||
self.__active_zone = None
|
||||
self.__in_zones = None
|
||||
@@ -317,14 +332,120 @@ class BaseScannerEntity(BaseTrackerEntity):
|
||||
addresses being used to identify the device.
|
||||
"""
|
||||
|
||||
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
|
||||
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
|
||||
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Call when the scanner entity is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
if not self.registry_entry:
|
||||
return
|
||||
self._async_read_entity_options()
|
||||
|
||||
async def async_internal_will_remove_from_hass(self) -> None:
|
||||
"""Call when the scanner entity is about to be removed from hass."""
|
||||
await super().async_internal_will_remove_from_hass()
|
||||
if not self.registry_entry:
|
||||
return
|
||||
if self._scanner_option_associated_zone_unsub is not None:
|
||||
self._scanner_option_associated_zone_unsub()
|
||||
self._scanner_option_associated_zone_unsub = None
|
||||
self._async_clear_associated_zone_issue()
|
||||
|
||||
@callback
|
||||
def async_registry_entry_updated(self) -> None:
|
||||
"""Run when the entity registry entry has been updated."""
|
||||
self._async_read_entity_options()
|
||||
|
||||
@callback
|
||||
def _async_read_entity_options(self) -> None:
|
||||
"""Read entity options from the entity registry.
|
||||
|
||||
Called when the entity registry entry has been updated and before the
|
||||
scanner entity is added to the state machine.
|
||||
"""
|
||||
assert self.registry_entry
|
||||
if (scanner_options := self.registry_entry.options.get(DOMAIN)) and (
|
||||
associated_zone := scanner_options.get(CONF_ASSOCIATED_ZONE)
|
||||
):
|
||||
new_zone = associated_zone
|
||||
else:
|
||||
new_zone = zone.ENTITY_ID_HOME
|
||||
|
||||
if new_zone == self._scanner_option_associated_zone:
|
||||
return
|
||||
|
||||
# Tear down tracking for the previous zone.
|
||||
if self._scanner_option_associated_zone_unsub is not None:
|
||||
self._scanner_option_associated_zone_unsub()
|
||||
self._scanner_option_associated_zone_unsub = None
|
||||
self._async_clear_associated_zone_issue()
|
||||
|
||||
self._scanner_option_associated_zone = new_zone
|
||||
|
||||
# zone.home is always present so no tracking or issue handling needed.
|
||||
if new_zone == zone.ENTITY_ID_HOME:
|
||||
return
|
||||
|
||||
self._scanner_option_associated_zone_unsub = async_track_state_change_event(
|
||||
self.hass, new_zone, self._async_associated_zone_state_changed
|
||||
)
|
||||
if self.hass.states.get(new_zone) is None:
|
||||
self._async_create_associated_zone_issue()
|
||||
|
||||
@callback
|
||||
def _async_associated_zone_state_changed(
|
||||
self, event: Event[EventStateChangedData]
|
||||
) -> None:
|
||||
"""Open or clear the repair issue when the associated zone appears or disappears."""
|
||||
if event.data["new_state"] is None:
|
||||
self._async_create_associated_zone_issue()
|
||||
else:
|
||||
self._async_clear_associated_zone_issue()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _async_create_associated_zone_issue(self) -> None:
|
||||
"""Create a repair issue prompting the user to reconfigure the scanner."""
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
self._associated_zone_issue_id,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="associated_zone_missing",
|
||||
translation_placeholders={
|
||||
"entity_id": self.entity_id,
|
||||
"zone": self._scanner_option_associated_zone,
|
||||
},
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_clear_associated_zone_issue(self) -> None:
|
||||
"""Clear the associated-zone-missing repair issue if it exists."""
|
||||
ir.async_delete_issue(self.hass, DOMAIN, self._associated_zone_issue_id)
|
||||
|
||||
@property
|
||||
def _associated_zone_issue_id(self) -> str:
|
||||
"""Return the issue id for the associated-zone-missing repair."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.registry_entry
|
||||
return f"associated_zone_missing_{self.registry_entry.id}"
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the device."""
|
||||
if self.is_connected is None:
|
||||
return None
|
||||
if self.is_connected:
|
||||
if not self.is_connected:
|
||||
return STATE_NOT_HOME
|
||||
associated_zone = self._scanner_option_associated_zone
|
||||
if associated_zone == zone.ENTITY_ID_HOME:
|
||||
return STATE_HOME
|
||||
return STATE_NOT_HOME
|
||||
if zone_state := self.hass.states.get(associated_zone):
|
||||
return zone_state.name
|
||||
# Configured zone has been removed; state is unknown.
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool | None:
|
||||
@@ -341,9 +462,18 @@ class BaseScannerEntity(BaseTrackerEntity):
|
||||
if not self.is_connected:
|
||||
return attr
|
||||
|
||||
associated_zone = self._scanner_option_associated_zone
|
||||
# If the configured zone has been removed, in_zones stays empty so the
|
||||
# attribute does not claim membership in a zone that no longer exists.
|
||||
if (
|
||||
associated_zone != zone.ENTITY_ID_HOME
|
||||
and self.hass.states.get(associated_zone) is None
|
||||
):
|
||||
return attr
|
||||
|
||||
attr[ATTR_IN_ZONES] = [
|
||||
zone.ENTITY_ID_HOME,
|
||||
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
|
||||
associated_zone,
|
||||
*zone.async_get_enclosing_zones(self.hass, associated_zone),
|
||||
]
|
||||
|
||||
return attr
|
||||
|
||||
@@ -38,6 +38,9 @@ from homeassistant.const import (
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
async_create_platform_config_not_supported_issue,
|
||||
)
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_time_interval,
|
||||
async_track_utc_time_change,
|
||||
@@ -379,8 +382,8 @@ async def async_extract_config(
|
||||
if platform.type == PLATFORM_TYPE_LEGACY:
|
||||
legacy.append(platform)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unable to determine type for {platform.name}: {platform.type}"
|
||||
async_create_platform_config_not_supported_issue(
|
||||
hass, platform.name, DOMAIN
|
||||
)
|
||||
|
||||
return legacy
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"associated_zone_missing": {
|
||||
"description": "The scanner entity `{entity_id}` is associated with the zone `{zone}`, but that zone has been removed.\n\nTo fix this, reconfigure the scanner to use a different zone or recreate the missing zone.",
|
||||
"title": "Scanner is associated with a removed zone"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"see": {
|
||||
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -64,23 +64,23 @@
|
||||
"ventilation_state": {
|
||||
"name": "Ventilation state",
|
||||
"state": {
|
||||
"aut1": "Automatic boost (15 min)",
|
||||
"aut2": "Automatic boost (30 min)",
|
||||
"aut3": "Automatic boost (45 min)",
|
||||
"auto": "Automatic",
|
||||
"cnt1": "Continuous low speed",
|
||||
"cnt2": "Continuous medium speed",
|
||||
"cnt3": "Continuous high speed",
|
||||
"empt": "Empty house",
|
||||
"man1": "Manual low speed (15 min)",
|
||||
"man1x2": "Manual low speed (30 min)",
|
||||
"man1x3": "Manual low speed (45 min)",
|
||||
"man2": "Manual medium speed (15 min)",
|
||||
"man2x2": "Manual medium speed (30 min)",
|
||||
"man2x3": "Manual medium speed (45 min)",
|
||||
"man3": "Manual high speed (15 min)",
|
||||
"man3x2": "Manual high speed (30 min)",
|
||||
"man3x3": "Manual high speed (45 min)"
|
||||
"aut1": "AUT1",
|
||||
"aut2": "AUT2",
|
||||
"aut3": "AUT3",
|
||||
"auto": "AUTO",
|
||||
"cnt1": "CNT1",
|
||||
"cnt2": "CNT2",
|
||||
"cnt3": "CNT3",
|
||||
"empt": "EMPT",
|
||||
"man1": "MAN1",
|
||||
"man1x2": "MAN1x2",
|
||||
"man1x3": "MAN1x3",
|
||||
"man2": "MAN2",
|
||||
"man2x2": "MAN2x2",
|
||||
"man2x3": "MAN2x3",
|
||||
"man3": "MAN3",
|
||||
"man3x2": "MAN3x2",
|
||||
"man3x3": "MAN3x3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -19,7 +19,7 @@ DEFAULT_STT_PROMPT = "Transcribe the attached audio"
|
||||
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
|
||||
RECOMMENDED_CHAT_MODEL = "models/gemini-3.1-flash-lite"
|
||||
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
|
||||
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
|
||||
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.entity import get_supported_features
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||
EntityTargetStateTriggerBase,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
@@ -18,7 +18,7 @@ from homeassistant.helpers.trigger import (
|
||||
|
||||
from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature
|
||||
|
||||
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
|
||||
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -15,7 +15,6 @@ CONF_INFO = "info"
|
||||
CONF_INVERTING = "inverting"
|
||||
CONF_LIGHT = "light"
|
||||
CONF_NODE = "node"
|
||||
CONF_NOTE = "note"
|
||||
CONF_OFF_ID = "off_id"
|
||||
CONF_ON_ID = "on_id"
|
||||
CONF_POSITION = "position"
|
||||
|
||||
@@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
|
||||
from homeassistant.const import (
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_NOTE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TYPE,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
@@ -25,7 +26,6 @@ from .const import (
|
||||
CONF_INFO,
|
||||
CONF_INVERTING,
|
||||
CONF_LIGHT,
|
||||
CONF_NOTE,
|
||||
CONF_OFF_ID,
|
||||
CONF_ON_ID,
|
||||
CONF_POSITION,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater
|
||||
|
||||
@@ -97,11 +97,13 @@ class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity):
|
||||
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
|
||||
|
||||
@property
|
||||
@override
|
||||
def is_on(self) -> bool:
|
||||
"""Return the status of the sensor."""
|
||||
return bool(self._heater.status[self.entity_description.value_key])
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the device state attributes."""
|
||||
if (attributes_fn := self.entity_description.extra_state_attributes_fn) is None:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway."""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom
|
||||
|
||||
@@ -76,16 +76,19 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
return {"status": self._room.status}
|
||||
|
||||
@property
|
||||
@override
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._room.room_temp
|
||||
|
||||
@property
|
||||
@override
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the actual current HVAC action."""
|
||||
if self._heater.is_burning and self._heater.is_pumping:
|
||||
@@ -93,6 +96,7 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
||||
return HVACAction.IDLE
|
||||
|
||||
@property
|
||||
@override
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the (override)temperature we try to reach.
|
||||
|
||||
@@ -106,11 +110,13 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
||||
return self._room.setpoint
|
||||
return self._room.override or self._room.setpoint
|
||||
|
||||
@override
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set a new target temperature for this zone."""
|
||||
temperature: float = kwargs[ATTR_TEMPERATURE]
|
||||
await self._room.set_override(temperature)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@override
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import InvalidGateway, InvalidHeaterList
|
||||
import voluptuous as vol
|
||||
@@ -100,6 +100,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
_discovered_host: str
|
||||
|
||||
@override
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
@@ -108,6 +109,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return InComfortOptionsFlowHandler()
|
||||
|
||||
@override
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
@@ -169,6 +171,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={CONF_HOST: self._discovered_host},
|
||||
)
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from incomfortclient import (
|
||||
@@ -74,6 +74,7 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
|
||||
)
|
||||
self.incomfort_data = incomfort_data
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> InComfortData:
|
||||
"""Fetch data from API endpoint."""
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater
|
||||
|
||||
@@ -104,11 +104,13 @@ class IncomfortSensor(IncomfortBoilerEntity, SensorEntity):
|
||||
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
|
||||
|
||||
@property
|
||||
@override
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return]
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the device state attributes."""
|
||||
if (extra_key := self.entity_description.extra_key) is None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater
|
||||
|
||||
@@ -49,11 +49,13 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
|
||||
self._attr_unique_id = heater.serial_no
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS}
|
||||
|
||||
@property
|
||||
@override
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self._heater.is_tapping:
|
||||
@@ -67,6 +69,7 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
|
||||
return max(self._heater.heater_temp, self._heater.tap_temp)
|
||||
|
||||
@property
|
||||
@override
|
||||
def current_operation(self) -> str | None:
|
||||
"""Return the current operation mode."""
|
||||
return self._heater.display_text
|
||||
|
||||
@@ -81,7 +81,6 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
|
||||
def async_on_update(self, event: NodeProperty) -> None:
|
||||
"""Save brightness in the update event from the ISY Node."""
|
||||
if self._node.status not in (0, ISY_VALUE_UNKNOWN):
|
||||
self._last_brightness = self._node.status
|
||||
if self._node.uom == UOM_PERCENTAGE:
|
||||
self._last_brightness = round(self._node.status * 255.0 / 100.0)
|
||||
else:
|
||||
|
||||
@@ -279,10 +279,6 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity):
|
||||
if uom in (UOM_INDEX, UOM_ON_OFF):
|
||||
return cast(str, self.target.formatted)
|
||||
|
||||
# Check if this is an index type and get formatted value
|
||||
if uom == UOM_INDEX and hasattr(self.target, "formatted"):
|
||||
return cast(str, self.target.formatted)
|
||||
|
||||
# Handle ISY precision and rounding
|
||||
value = convert_isy_value_to_hass(value, uom, self.target.prec)
|
||||
if value is None:
|
||||
|
||||
@@ -8,8 +8,6 @@ import datetime
|
||||
from functools import partial
|
||||
from random import random
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.labs import (
|
||||
EventLabsUpdatedData,
|
||||
async_is_preview_feature_enabled,
|
||||
@@ -34,7 +32,7 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
@@ -51,9 +49,11 @@ from homeassistant.util.unit_conversion import (
|
||||
)
|
||||
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .services import async_setup_services
|
||||
|
||||
COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
Platform.BUTTON,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.FAN,
|
||||
Platform.EVENT,
|
||||
Platform.IMAGE,
|
||||
@@ -69,15 +69,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
|
||||
{
|
||||
vol.Required("field_1"): vol.Coerce(int),
|
||||
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
|
||||
vol.Optional("field_3"): vol.Coerce(int),
|
||||
vol.Optional("field_4"): vol.In(["forwards", "reverse"]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the demo environment."""
|
||||
@@ -87,24 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def service_handler(call: ServiceCall | None = None) -> ServiceResponse:
|
||||
"""Do nothing."""
|
||||
return None
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"test_service_1",
|
||||
service_handler,
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1,
|
||||
description_placeholders={
|
||||
"meep_1": "foo",
|
||||
"meep_2": "bar",
|
||||
"meep_3": "beer",
|
||||
"meep_4": "milk",
|
||||
"meep_5": "https://example.com",
|
||||
},
|
||||
)
|
||||
async_setup_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Demo platform that has a couple of fake device trackers."""
|
||||
|
||||
from homeassistant.components.device_tracker import (
|
||||
BaseScannerEntity,
|
||||
SourceType,
|
||||
TrackerEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Everything but the Kitchen Sink config entry."""
|
||||
async_add_entities(
|
||||
[
|
||||
DemoTracker(
|
||||
unique_id="kitchen_sink_tracker_001",
|
||||
name="Demo tracker",
|
||||
latitude=hass.config.latitude,
|
||||
longitude=hass.config.longitude,
|
||||
accuracy=10,
|
||||
),
|
||||
DemoScanner(
|
||||
unique_id="kitchen_sink_scanner_001",
|
||||
name="Demo scanner",
|
||||
is_connected=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class DemoTracker(TrackerEntity):
|
||||
"""Representation of a demo tracker."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_source_type = SourceType.GPS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unique_id: str,
|
||||
name: str,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
accuracy: float,
|
||||
) -> None:
|
||||
"""Initialize the tracker."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_name = name
|
||||
self._attr_latitude = latitude
|
||||
self._attr_longitude = longitude
|
||||
self._attr_location_accuracy = accuracy
|
||||
|
||||
@callback
|
||||
def async_set_tracker_location(
|
||||
self, latitude: float, longitude: float, accuracy: float
|
||||
) -> None:
|
||||
"""Update the tracker location."""
|
||||
self._attr_latitude = latitude
|
||||
self._attr_longitude = longitude
|
||||
self._attr_location_accuracy = accuracy
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class DemoScanner(BaseScannerEntity):
|
||||
"""Representation of a demo scanner."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_source_type = SourceType.ROUTER
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unique_id: str,
|
||||
name: str,
|
||||
is_connected: bool,
|
||||
) -> None:
|
||||
"""Initialize the scanner."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_name = name
|
||||
self._is_connected = is_connected
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return true if the device is connected."""
|
||||
return self._is_connected
|
||||
|
||||
@callback
|
||||
def async_set_scanner_connected(self, connected: bool) -> None:
|
||||
"""Update the scanner connected state."""
|
||||
self._is_connected = connected
|
||||
self.async_write_ha_state()
|
||||
@@ -9,6 +9,12 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_scanner_connected": {
|
||||
"service": "mdi:lan-connect"
|
||||
},
|
||||
"set_tracker_location": {
|
||||
"service": "mdi:map-marker"
|
||||
},
|
||||
"test_service_1": {
|
||||
"sections": {
|
||||
"additional_fields": "mdi:test-tube"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Services for the Everything but the Kitchen Sink integration."""
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
|
||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
|
||||
{
|
||||
vol.Required("field_1"): vol.Coerce(int),
|
||||
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
|
||||
vol.Optional("field_3"): vol.Coerce(int),
|
||||
vol.Optional("field_4"): vol.In(["forward", "reverse"]),
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_TEST_SERVICE_1 = "test_service_1"
|
||||
SERVICE_SET_TRACKER_LOCATION = "set_tracker_location"
|
||||
SERVICE_SET_SCANNER_CONNECTED = "set_scanner_connected"
|
||||
|
||||
ATTR_ACCURACY = "accuracy"
|
||||
ATTR_CONNECTED = "connected"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for the Kitchen Sink integration."""
|
||||
|
||||
@callback
|
||||
def service_handler(call: ServiceCall) -> ServiceResponse:
|
||||
"""Do nothing."""
|
||||
return None
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_TEST_SERVICE_1,
|
||||
service_handler,
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1,
|
||||
description_placeholders={
|
||||
"meep_1": "foo",
|
||||
"meep_2": "bar",
|
||||
"meep_3": "beer",
|
||||
"meep_4": "milk",
|
||||
"meep_5": "https://example.com",
|
||||
},
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_TRACKER_LOCATION,
|
||||
entity_domain=DEVICE_TRACKER_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_LATITUDE): cv.latitude,
|
||||
vol.Required(ATTR_LONGITUDE): cv.longitude,
|
||||
vol.Required(ATTR_ACCURACY): vol.All(vol.Coerce(float), vol.Range(min=0)),
|
||||
},
|
||||
func="async_set_tracker_location",
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_SCANNER_CONNECTED,
|
||||
entity_domain=DEVICE_TRACKER_DOMAIN,
|
||||
schema={vol.Required(ATTR_CONNECTED): cv.boolean},
|
||||
func="async_set_scanner_connected",
|
||||
)
|
||||
@@ -30,3 +30,44 @@ test_service_1:
|
||||
options:
|
||||
- "forward"
|
||||
- "reverse"
|
||||
set_tracker_location:
|
||||
target:
|
||||
entity:
|
||||
integration: kitchen_sink
|
||||
domain: device_tracker
|
||||
fields:
|
||||
latitude:
|
||||
required: true
|
||||
example: 52.379189
|
||||
selector:
|
||||
number:
|
||||
min: -90
|
||||
max: 90
|
||||
step: any
|
||||
longitude:
|
||||
required: true
|
||||
example: 4.899431
|
||||
selector:
|
||||
number:
|
||||
min: -180
|
||||
max: 180
|
||||
step: any
|
||||
accuracy:
|
||||
required: true
|
||||
example: 10
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 10000
|
||||
unit_of_measurement: m
|
||||
set_scanner_connected:
|
||||
target:
|
||||
entity:
|
||||
integration: kitchen_sink
|
||||
domain: device_tracker
|
||||
fields:
|
||||
connected:
|
||||
required: true
|
||||
example: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
@@ -135,6 +135,34 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_scanner_connected": {
|
||||
"description": "Sets the connected state of a demo scanner entity.",
|
||||
"fields": {
|
||||
"connected": {
|
||||
"description": "Whether the device should be reported as connected.",
|
||||
"name": "Connected"
|
||||
}
|
||||
},
|
||||
"name": "Set scanner connected"
|
||||
},
|
||||
"set_tracker_location": {
|
||||
"description": "Sets the location and accuracy of a demo tracker entity.",
|
||||
"fields": {
|
||||
"accuracy": {
|
||||
"description": "Location accuracy in meters.",
|
||||
"name": "Accuracy"
|
||||
},
|
||||
"latitude": {
|
||||
"description": "Latitude of the new location.",
|
||||
"name": "Latitude"
|
||||
},
|
||||
"longitude": {
|
||||
"description": "Longitude of the new location.",
|
||||
"name": "Longitude"
|
||||
}
|
||||
},
|
||||
"name": "Set tracker location"
|
||||
},
|
||||
"test_service_1": {
|
||||
"description": "Fake action for testing {meep_2}",
|
||||
"fields": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Support for OPNsense Routers."""
|
||||
|
||||
import logging
|
||||
|
||||
from aiopnsense import (
|
||||
OPNsenseBelowMinFirmware,
|
||||
OPNsenseClient,
|
||||
@@ -15,22 +13,16 @@ from aiopnsense import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.discovery import load_platform
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_API_SECRET,
|
||||
CONF_INTERFACE_CLIENT,
|
||||
CONF_TRACKER_INTERFACES,
|
||||
DOMAIN,
|
||||
OPNSENSE_DATA,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import CONF_API_SECRET, CONF_TRACKER_INTERFACES, DOMAIN
|
||||
from .types import OPNsenseConfigEntry, OPNsenseRuntimeData
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -49,86 +41,124 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
PLATFORMS = [Platform.DEVICE_TRACKER]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the opnsense component."""
|
||||
"""Set up the OPNsense component."""
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
conf = config[DOMAIN]
|
||||
url = conf[CONF_URL]
|
||||
api_key = conf[CONF_API_KEY]
|
||||
api_secret = conf[CONF_API_SECRET]
|
||||
verify_ssl = conf[CONF_VERIFY_SSL]
|
||||
tracker_interfaces = conf[CONF_TRACKER_INTERFACES]
|
||||
hass.async_create_task(_async_setup(hass, config))
|
||||
|
||||
session = async_get_clientsession(hass, verify_ssl=verify_ssl)
|
||||
return True
|
||||
|
||||
|
||||
async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None:
|
||||
"""Set up the OPNsense component from YAML."""
|
||||
await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=config[DOMAIN],
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: OPNsenseConfigEntry
|
||||
) -> bool:
|
||||
"""Set up the OPNsense component from a config entry."""
|
||||
url = config_entry.data[CONF_URL]
|
||||
session = async_get_clientsession(
|
||||
hass, verify_ssl=config_entry.data[CONF_VERIFY_SSL]
|
||||
)
|
||||
client = OPNsenseClient(
|
||||
url,
|
||||
api_key,
|
||||
api_secret,
|
||||
config_entry.data[CONF_API_KEY],
|
||||
config_entry.data[CONF_API_SECRET],
|
||||
session,
|
||||
opts={"verify_ssl": verify_ssl},
|
||||
opts={"verify_ssl": config_entry.data[CONF_VERIFY_SSL]},
|
||||
)
|
||||
tracker_interfaces = config_entry.data.get(CONF_TRACKER_INTERFACES, [])
|
||||
try:
|
||||
await client.validate()
|
||||
if tracker_interfaces:
|
||||
interfaces_resp = await client.get_interfaces()
|
||||
except OPNsenseUnknownFirmware:
|
||||
_LOGGER.error("Error checking the OPNsense firmware version at %s", url)
|
||||
return False
|
||||
except OPNsenseBelowMinFirmware:
|
||||
_LOGGER.error(
|
||||
"OPNsense Firmware is below the minimum supported version at %s", url
|
||||
)
|
||||
return False
|
||||
except OPNsenseInvalidURL:
|
||||
_LOGGER.error(
|
||||
"Invalid URL while connecting to OPNsense API endpoint at %s", url
|
||||
)
|
||||
return False
|
||||
except OPNsenseTimeoutError:
|
||||
_LOGGER.error("Timeout while connecting to OPNsense API endpoint at %s", url)
|
||||
return False
|
||||
except OPNsenseSSLError:
|
||||
_LOGGER.error(
|
||||
"Unable to verify SSL while connecting to OPNsense API endpoint at %s", url
|
||||
)
|
||||
return False
|
||||
except OPNsenseInvalidAuth:
|
||||
_LOGGER.error(
|
||||
"Authentication failure while connecting to OPNsense API endpoint at %s",
|
||||
url,
|
||||
)
|
||||
return False
|
||||
except OPNsensePrivilegeMissing:
|
||||
_LOGGER.error(
|
||||
"Invalid Permissions while connecting to OPNsense API endpoint at %s",
|
||||
url,
|
||||
)
|
||||
return False
|
||||
except OPNsenseConnectionError:
|
||||
_LOGGER.error(
|
||||
"Connection failure while connecting to OPNsense API endpoint at %s",
|
||||
url,
|
||||
)
|
||||
return False
|
||||
except OPNsenseUnknownFirmware as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_firmware",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseBelowMinFirmware as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="firmware_too_old",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseInvalidURL as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_url",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseTimeoutError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connecting",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseSSLError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="ssl_error",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseInvalidAuth as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsensePrivilegeMissing as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="privilege_missing",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseConnectionError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
|
||||
if tracker_interfaces:
|
||||
# Verify that specified tracker interfaces are valid
|
||||
known_interfaces = [
|
||||
ifinfo.get("name", "") for ifinfo in interfaces_resp.values()
|
||||
name for ifinfo in interfaces_resp.values() if (name := ifinfo.get("name"))
|
||||
]
|
||||
for intf_description in tracker_interfaces:
|
||||
if intf_description not in known_interfaces:
|
||||
_LOGGER.error(
|
||||
"Specified OPNsense tracker interface %s is not found",
|
||||
intf_description,
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="tracker_interface_not_found",
|
||||
translation_placeholders={
|
||||
"interface": intf_description,
|
||||
"known": ", ".join(known_interfaces),
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
hass.data[OPNSENSE_DATA] = {
|
||||
CONF_INTERFACE_CLIENT: client,
|
||||
CONF_TRACKER_INTERFACES: tracker_interfaces,
|
||||
}
|
||||
config_entry.runtime_data = OPNsenseRuntimeData(
|
||||
client=client,
|
||||
tracker_interfaces=tracker_interfaces,
|
||||
)
|
||||
|
||||
load_platform(hass, Platform.DEVICE_TRACKER, DOMAIN, tracker_interfaces, config)
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: OPNsenseConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Config flow for OPNsense."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiopnsense import (
|
||||
OPNsenseBelowMinFirmware,
|
||||
OPNsenseClient,
|
||||
OPNsenseConnectionError,
|
||||
OPNsenseInvalidAuth,
|
||||
OPNsenseInvalidURL,
|
||||
OPNsensePrivilegeMissing,
|
||||
OPNsenseSSLError,
|
||||
OPNsenseTimeoutError,
|
||||
OPNsenseUnknownFirmware,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_API_SECRET, CONF_TRACKER_INTERFACES, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_URL): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(CONF_API_SECRET): str,
|
||||
vol.Required(CONF_VERIFY_SSL, default=True): bool,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def tracker_interfaces_schema(
|
||||
interfaces: list[str], selected: list[str] | None = None
|
||||
) -> vol.Schema:
|
||||
"""Schema to display available interfaces for device tracking selection."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_TRACKER_INTERFACES,
|
||||
default=selected or [],
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=interfaces, mode=SelectSelectorMode.DROPDOWN, multiple=True
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OPNsenseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""OPNsense config flow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize OPNsense config flow."""
|
||||
self.available_interfaces: list[str] | None = None
|
||||
self._entry_data: dict[str, Any] = {}
|
||||
|
||||
async def _show_setup_form(
|
||||
self,
|
||||
user_input: dict[Any, Any] | None = None,
|
||||
errors: dict[Any, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the setup form to the user."""
|
||||
if user_input is None:
|
||||
user_input = {}
|
||||
|
||||
description_placeholders = {
|
||||
"doc_url": "https://www.home-assistant.io/integrations/opnsense/"
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors or {},
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def _show_interfaces_form(
|
||||
self,
|
||||
user_input: dict[Any, Any],
|
||||
errors: dict[Any, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the tracker interfaces selection form to the user."""
|
||||
return self.async_show_form(
|
||||
step_id="interfaces",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
tracker_interfaces_schema(
|
||||
self.available_interfaces or [],
|
||||
user_input.get(CONF_TRACKER_INTERFACES),
|
||||
),
|
||||
user_input,
|
||||
),
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user step: credentials and connection test."""
|
||||
errors = {}
|
||||
|
||||
if user_input is None:
|
||||
return await self._show_setup_form(user_input, None)
|
||||
|
||||
verify_ssl = user_input[CONF_VERIFY_SSL]
|
||||
session = async_get_clientsession(self.hass, verify_ssl=verify_ssl)
|
||||
client = OPNsenseClient(
|
||||
user_input[CONF_URL],
|
||||
user_input[CONF_API_KEY],
|
||||
user_input[CONF_API_SECRET],
|
||||
session,
|
||||
opts={"verify_ssl": verify_ssl},
|
||||
)
|
||||
|
||||
try:
|
||||
await client.validate()
|
||||
interfaces_resp = await client.get_interfaces()
|
||||
known_interfaces = [
|
||||
name
|
||||
for ifinfo in interfaces_resp.values()
|
||||
if (name := ifinfo.get("name"))
|
||||
]
|
||||
self.available_interfaces = list(known_interfaces)
|
||||
except OPNsenseInvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except OPNsensePrivilegeMissing:
|
||||
errors["base"] = "privilege_missing"
|
||||
except OPNsenseInvalidURL:
|
||||
errors["base"] = "invalid_url"
|
||||
except OPNsenseSSLError:
|
||||
errors["base"] = "ssl_error"
|
||||
except OPNsenseConnectionError, OPNsenseTimeoutError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except OPNsenseUnknownFirmware:
|
||||
errors["base"] = "unknown_version"
|
||||
except OPNsenseBelowMinFirmware:
|
||||
errors["base"] = "invalid_version"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
unique_id = await client.get_device_unique_id()
|
||||
if not unique_id:
|
||||
return self.async_abort(reason="no_unique_id")
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
self._entry_data = user_input
|
||||
return await self.async_step_interfaces()
|
||||
|
||||
return await self._show_setup_form(user_input, errors)
|
||||
|
||||
async def async_step_interfaces(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle tracker interface selection step."""
|
||||
if user_input is None:
|
||||
return await self._show_interfaces_form({}, None)
|
||||
|
||||
if user_input.get(CONF_TRACKER_INTERFACES):
|
||||
self._entry_data[CONF_TRACKER_INTERFACES] = user_input[
|
||||
CONF_TRACKER_INTERFACES
|
||||
]
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self._entry_data[CONF_URL], data=self._entry_data
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import a Yaml config."""
|
||||
# Test connection
|
||||
session = async_get_clientsession(
|
||||
self.hass, verify_ssl=import_data[CONF_VERIFY_SSL]
|
||||
)
|
||||
client = OPNsenseClient(
|
||||
import_data[CONF_URL],
|
||||
import_data[CONF_API_KEY],
|
||||
import_data[CONF_API_SECRET],
|
||||
session,
|
||||
opts={"verify_ssl": import_data[CONF_VERIFY_SSL]},
|
||||
)
|
||||
try:
|
||||
await client.validate()
|
||||
interfaces_resp = await client.get_interfaces()
|
||||
except OPNsenseInvalidURL:
|
||||
return self._abort_import(reason="invalid_url")
|
||||
except OPNsenseInvalidAuth:
|
||||
return self._abort_import(reason="invalid_auth")
|
||||
except OPNsensePrivilegeMissing:
|
||||
return self._abort_import(reason="privilege_missing")
|
||||
except OPNsenseSSLError:
|
||||
return self._abort_import(reason="ssl_error")
|
||||
except OPNsenseConnectionError, OPNsenseTimeoutError:
|
||||
return self._abort_import(reason="cannot_connect")
|
||||
except OPNsenseUnknownFirmware:
|
||||
return self._abort_import(reason="unknown_version")
|
||||
except OPNsenseBelowMinFirmware:
|
||||
return self._abort_import(reason="invalid_version")
|
||||
except Exception: # Allowed in config flows
|
||||
_LOGGER.exception("Unexpected exception during import")
|
||||
return self._abort_import(reason="unknown")
|
||||
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version="2026.12.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "OPNsense",
|
||||
},
|
||||
)
|
||||
|
||||
unique_id = await client.get_device_unique_id()
|
||||
if not unique_id:
|
||||
return self._abort_import(reason="no_unique_id")
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Validate CONF_TRACKER_INTERFACES if present and not empty
|
||||
verified_data = dict(import_data)
|
||||
if CONF_TRACKER_INTERFACES in verified_data:
|
||||
if not verified_data[CONF_TRACKER_INTERFACES]:
|
||||
verified_data.pop(CONF_TRACKER_INTERFACES)
|
||||
else:
|
||||
known_interfaces = [
|
||||
name
|
||||
for ifinfo in interfaces_resp.values()
|
||||
if (name := ifinfo.get("name"))
|
||||
]
|
||||
self.available_interfaces = sorted(known_interfaces)
|
||||
# Abort import if any specified tracker interface is not found
|
||||
missing = [
|
||||
intf_description
|
||||
for intf_description in verified_data[CONF_TRACKER_INTERFACES]
|
||||
if intf_description not in known_interfaces
|
||||
]
|
||||
if missing:
|
||||
# Create a repair to guide the user
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"import_failed_missing_interfaces",
|
||||
breaks_in_ha_version="2026.12.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.ERROR,
|
||||
translation_key="import_failed_missing_interfaces",
|
||||
translation_placeholders={
|
||||
"missing": ", ".join(missing),
|
||||
"found": ", ".join(known_interfaces),
|
||||
"integration_title": "OPNsense",
|
||||
},
|
||||
)
|
||||
return self.async_abort(
|
||||
reason="import_failed_missing_interfaces",
|
||||
description_placeholders={
|
||||
"missing": ", ".join(missing),
|
||||
"found": ", ".join(known_interfaces),
|
||||
"integration_title": "OPNsense",
|
||||
},
|
||||
)
|
||||
|
||||
# Clear any previous import issues if interfaces are now valid
|
||||
async_delete_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"import_failed_missing_interfaces",
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=verified_data[CONF_URL], data=verified_data
|
||||
)
|
||||
|
||||
def _abort_import(self, reason: str) -> ConfigFlowResult:
|
||||
"""Create an issue for import errors and abort the import."""
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"import_failed_{reason}",
|
||||
breaks_in_ha_version="2026.12.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.ERROR,
|
||||
translation_key=f"import_failed_{reason}",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "OPNsense",
|
||||
},
|
||||
)
|
||||
return self.async_abort(
|
||||
reason=reason,
|
||||
description_placeholders={
|
||||
"integration_title": "OPNsense",
|
||||
},
|
||||
)
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Constants for OPNsense component."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "opnsense"
|
||||
OPNSENSE_DATA = DOMAIN
|
||||
|
||||
CONF_API_SECRET = "api_secret"
|
||||
CONF_INTERFACE_CLIENT = "interface_client"
|
||||
CONF_TRACKER_INTERFACES = "tracker_interfaces"
|
||||
|
||||
# Update interval for device scanning
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Coordinator for OPNsense device tracker updates."""
|
||||
|
||||
import logging
|
||||
|
||||
from aiopnsense import (
|
||||
OPNsenseBelowMinFirmware,
|
||||
OPNsenseClient,
|
||||
OPNsenseConnectionError,
|
||||
OPNsenseInvalidAuth,
|
||||
OPNsenseInvalidURL,
|
||||
OPNsensePrivilegeMissing,
|
||||
OPNsenseSSLError,
|
||||
OPNsenseTimeoutError,
|
||||
OPNsenseUnknownFirmware,
|
||||
)
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import SCAN_INTERVAL
|
||||
from .types import DeviceDetails, DeviceDetailsByMAC, OPNsenseConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OPNsenseDeviceTrackerCoordinator(DataUpdateCoordinator[DeviceDetailsByMAC]):
|
||||
"""Coordinator for OPNsense device tracker updates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: OPNsenseConfigEntry,
|
||||
client: OPNsenseClient,
|
||||
interfaces: list[str],
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="OPNsense Device Tracker",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.client = client
|
||||
self.interfaces = interfaces
|
||||
self.tracked_devices: set[str] = set()
|
||||
|
||||
def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC:
|
||||
"""Create dict with mac address keys from list of devices."""
|
||||
out_devices: DeviceDetailsByMAC = {}
|
||||
for device in devices:
|
||||
if not self.interfaces or device["intf_description"] in self.interfaces:
|
||||
formatted_mac = format_mac(device["mac"])
|
||||
out_devices[formatted_mac] = device
|
||||
return out_devices
|
||||
|
||||
async def _async_update_data(self) -> DeviceDetailsByMAC:
|
||||
"""Fetch data from OPNsense."""
|
||||
try:
|
||||
devices = await self.client.get_arp_table(True)
|
||||
except (
|
||||
OPNsenseInvalidAuth,
|
||||
OPNsenseInvalidURL,
|
||||
OPNsensePrivilegeMissing,
|
||||
OPNsenseSSLError,
|
||||
OPNsenseBelowMinFirmware,
|
||||
OPNsenseUnknownFirmware,
|
||||
) as err:
|
||||
raise ConfigEntryError(f"Error with OPNsense configuration: {err}") from err
|
||||
except (
|
||||
OPNsenseConnectionError,
|
||||
OPNsenseTimeoutError,
|
||||
) as err:
|
||||
raise UpdateFailed(
|
||||
f"Error communicating with OPNsense router: {err}"
|
||||
) from err
|
||||
|
||||
return self._get_mac_addrs(devices)
|
||||
@@ -1,71 +1,117 @@
|
||||
"""Device tracker support for OPNsense routers."""
|
||||
|
||||
from typing import Any, NewType
|
||||
from typing import Any
|
||||
|
||||
from aiopnsense import OPNsenseClient
|
||||
|
||||
from homeassistant.components.device_tracker import DeviceScanner
|
||||
from homeassistant.components.device_tracker import ScannerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_INTERFACE_CLIENT, CONF_TRACKER_INTERFACES, OPNSENSE_DATA
|
||||
|
||||
DeviceDetails = NewType("DeviceDetails", dict[str, Any])
|
||||
DeviceDetailsByMAC = NewType("DeviceDetailsByMAC", dict[str, DeviceDetails])
|
||||
from .coordinator import OPNsenseDeviceTrackerCoordinator
|
||||
from .types import DeviceDetails, OPNsenseConfigEntry
|
||||
|
||||
|
||||
async def async_get_scanner(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> DeviceScanner | None:
|
||||
"""Configure the OPNsense device_tracker."""
|
||||
return OPNsenseDeviceScanner(
|
||||
hass.data[OPNSENSE_DATA][CONF_INTERFACE_CLIENT],
|
||||
hass.data[OPNSENSE_DATA][CONF_TRACKER_INTERFACES],
|
||||
)
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OPNsenseConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up device tracker for OPNsense component."""
|
||||
client = entry.runtime_data.client
|
||||
interfaces = entry.runtime_data.tracker_interfaces
|
||||
|
||||
coordinator = OPNsenseDeviceTrackerCoordinator(hass, entry, client, interfaces)
|
||||
|
||||
def _async_add_new_entities() -> None:
|
||||
"""Add entities for newly discovered devices."""
|
||||
if not coordinator.data:
|
||||
return
|
||||
|
||||
entities = []
|
||||
for mac_address in coordinator.data:
|
||||
if mac_address in coordinator.tracked_devices:
|
||||
continue
|
||||
entity = OPNsenseDeviceTrackerEntity(coordinator, mac_address)
|
||||
coordinator.tracked_devices.add(mac_address)
|
||||
entities.append(entity)
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
|
||||
|
||||
# Initial data fetch
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
_async_add_new_entities()
|
||||
|
||||
|
||||
class OPNsenseDeviceScanner(DeviceScanner):
|
||||
"""This class queries a router running OPNsense."""
|
||||
class OPNsenseDeviceTrackerEntity(
|
||||
CoordinatorEntity[OPNsenseDeviceTrackerCoordinator], ScannerEntity
|
||||
):
|
||||
"""Representation of a tracked device."""
|
||||
|
||||
def __init__(self, client: OPNsenseClient, interfaces: list[str]) -> None:
|
||||
"""Initialize the scanner."""
|
||||
self.last_results: dict[str, Any] = {}
|
||||
self.client = client
|
||||
self.interfaces = interfaces
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: OPNsenseDeviceTrackerCoordinator,
|
||||
mac_address: str,
|
||||
) -> None:
|
||||
"""Initialize the device tracker entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_mac_address = mac_address
|
||||
|
||||
def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC | dict:
|
||||
"""Create dict with mac address keys from list of devices."""
|
||||
out_devices = {}
|
||||
for device in devices:
|
||||
if not self.interfaces or device["intf_description"] in self.interfaces:
|
||||
out_devices[device["mac"]] = device
|
||||
return out_devices
|
||||
@property
|
||||
def device_data(self) -> DeviceDetails | None:
|
||||
"""Return device data for current device."""
|
||||
if self.coordinator.data and self.mac_address in self.coordinator.data:
|
||||
return self.coordinator.data[self.mac_address]
|
||||
return None
|
||||
|
||||
async def async_scan_devices(self) -> list[str]:
|
||||
"""Scan for new devices and return a list with found device IDs."""
|
||||
await self._async_update_info()
|
||||
return list(self.last_results)
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return true if the device is connected to the network."""
|
||||
return (
|
||||
self.coordinator.data is not None
|
||||
and self.mac_address in self.coordinator.data
|
||||
)
|
||||
|
||||
def get_device_name(self, device: str) -> str | None:
|
||||
"""Return the name of the given device or None if we don't know."""
|
||||
if device not in self.last_results:
|
||||
return None
|
||||
return self.last_results[device].get("hostname") or None
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return device name."""
|
||||
device_data = self.device_data
|
||||
if device_data and device_data.get("hostname"):
|
||||
return str(device_data["hostname"])
|
||||
return f"OPNsense {self.mac_address}"
|
||||
|
||||
async def _async_update_info(self) -> bool:
|
||||
"""Ensure the information from the OPNsense router is up to date.
|
||||
@property
|
||||
def ip_address(self) -> str | None:
|
||||
"""Return the primary IP address of the device."""
|
||||
device_data = self.device_data
|
||||
if device_data:
|
||||
return device_data.get("ip")
|
||||
return None
|
||||
|
||||
Return boolean if scanning successful.
|
||||
"""
|
||||
devices = await self.client.get_arp_table(True)
|
||||
self.last_results = self._get_mac_addrs(devices)
|
||||
return True
|
||||
@property
|
||||
def hostname(self) -> str | None:
|
||||
"""Return hostname of the device."""
|
||||
device_data = self.device_data
|
||||
if device_data:
|
||||
hostname = device_data.get("hostname")
|
||||
return hostname or None
|
||||
return None
|
||||
|
||||
def get_extra_attributes(self, device: str) -> dict[Any, Any]:
|
||||
"""Return the extra attrs of the given device."""
|
||||
if device not in self.last_results:
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
device_data = self.device_data
|
||||
if not device_data:
|
||||
return {}
|
||||
mfg = self.last_results[device].get("manufacturer")
|
||||
if not mfg:
|
||||
return {}
|
||||
return {"manufacturer": mfg}
|
||||
|
||||
attrs = {}
|
||||
if manufacturer := device_data.get("manufacturer"):
|
||||
attrs["manufacturer"] = manufacturer
|
||||
if interface := device_data.get("intf_description"):
|
||||
attrs["interface"] = interface
|
||||
if expires := device_data.get("expires"):
|
||||
attrs["expires"] = expires
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"domain": "opnsense",
|
||||
"name": "OPNsense",
|
||||
"codeowners": ["@HarlemSquirrel", "@Snuffy2"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/opnsense",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"import_failed_missing_interfaces": "The following OPNsense tracker interfaces were not found: {missing}. Found interfaces were: {found}. Please manually set up a config entry and remove the YAML config",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_url": "URL is invalid or unreachable",
|
||||
"invalid_version": "Unsupported OPNsense firmware version",
|
||||
"no_unique_id": "Could not determine a unique identifier for this OPNsense router. Please ensure the API key has sufficient privileges, and your OPNsense instance has physical ports with a MAC address.",
|
||||
"privilege_missing": "The API key used does not have sufficient privileges. Please check the integration documentation for required permissions",
|
||||
"ssl_error": "SSL certificate verification failed",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unknown_version": "The OPNsense firmware version is unknown. Please ensure you are running a supported version of OPNsense and the user has the correct permissions."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_interface": "Interface(s) do not exist",
|
||||
"invalid_url": "URL is invalid or unreachable",
|
||||
"invalid_version": "Unsupported OPNsense firmware version",
|
||||
"privilege_missing": "[%key:component::opnsense::config::abort::privilege_missing%]",
|
||||
"ssl_error": "SSL certificate verification failed",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unknown_version": "The OPNsense firmware version is unknown. Please ensure you are running a supported version of OPNsense and the user has the correct permissions."
|
||||
},
|
||||
"step": {
|
||||
"interfaces": {
|
||||
"data": {
|
||||
"tracker_interfaces": "Interface(s) to use for tracking devices"
|
||||
},
|
||||
"description": "Select the OPNsense interfaces to use for tracking devices. If no interfaces are selected then all interfaces will be used for tracking."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"api_secret": "API secret",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"description": "Set required parameters to connect to your router. For more information, please refer to the [integration documentation]({doc_url})"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Connection failure while connecting to OPNsense API endpoint at {url}"
|
||||
},
|
||||
"firmware_too_old": {
|
||||
"message": "OPNsense firmware at {url} is below the minimum supported version"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Authentication failure while connecting to OPNsense API endpoint at {url}"
|
||||
},
|
||||
"invalid_url": {
|
||||
"message": "Invalid URL while connecting to OPNsense API endpoint at {url}"
|
||||
},
|
||||
"privilege_missing": {
|
||||
"message": "The API user connecting to {url} does not have sufficient privileges"
|
||||
},
|
||||
"ssl_error": {
|
||||
"message": "Unable to verify SSL certificate while connecting to OPNsense API endpoint at {url}"
|
||||
},
|
||||
"timeout_connecting": {
|
||||
"message": "Timeout while connecting to OPNsense API endpoint at {url}"
|
||||
},
|
||||
"tracker_interface_not_found": {
|
||||
"message": "Configured tracker interface {interface} is not present on the OPNsense router. Known interfaces: {known}"
|
||||
},
|
||||
"unknown_firmware": {
|
||||
"message": "Could not determine the OPNsense firmware version at {url}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"import_failed_cannot_connect": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
},
|
||||
"import_failed_invalid_auth": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an authentication error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
},
|
||||
"import_failed_invalid_url": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the URL provided is invalid or unreachable. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
},
|
||||
"import_failed_invalid_version": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the OPNsense firmware version is unsupported. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
},
|
||||
"import_failed_missing_interfaces": {
|
||||
"description": "The following OPNsense tracker interfaces were not found: {missing}. Found interfaces were: {found}. Please manually set up a config entry and remove the YAML config",
|
||||
"title": "The {integration_title} YAML import failed: Missing tracker interfaces"
|
||||
},
|
||||
"import_failed_no_unique_id": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a unique identifier for the router could not be determined. Please ensure the API key has sufficient privileges, and your OPNsense instance has physical ports with a MAC address.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
},
|
||||
"import_failed_privilege_missing": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the API key used does not have sufficient privileges. Please check the integration documentation for required permissions, correct your YAML configuration, and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
},
|
||||
"import_failed_ssl_error": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an SSL error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
},
|
||||
"import_failed_unknown": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an unknown error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
},
|
||||
"import_failed_unknown_version": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the OPNsense firmware version is unknown. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Types for OPNsense routers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from aiopnsense import OPNsenseClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OPNsenseRuntimeData:
|
||||
"""Runtime data for OPNsense config entries."""
|
||||
|
||||
client: OPNsenseClient
|
||||
tracker_interfaces: list[str]
|
||||
|
||||
|
||||
type DeviceDetails = dict[str, Any]
|
||||
type DeviceDetailsByMAC = dict[str, DeviceDetails]
|
||||
type OPNsenseConfigEntry = ConfigEntry[OPNsenseRuntimeData]
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/qingping",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["qingping-ble==1.1.4"]
|
||||
"requirements": ["qingping-ble==1.1.5"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -24,6 +24,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
|
||||
CONF_BC_CONNECT,
|
||||
CONF_BC_ONLY,
|
||||
CONF_BC_PORT,
|
||||
CONF_FIRMWARE_CHECK_TIME,
|
||||
@@ -102,6 +103,8 @@ async def async_setup_entry(
|
||||
!= config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE)
|
||||
or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT)
|
||||
or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY)
|
||||
or host.api.baichuan.connection_type.value
|
||||
!= config_entry.data.get(CONF_BC_CONNECT)
|
||||
):
|
||||
if host.api.port != config_entry.data[CONF_PORT]:
|
||||
_LOGGER.warning(
|
||||
@@ -126,6 +129,7 @@ async def async_setup_entry(
|
||||
CONF_USE_HTTPS: host.api.use_https,
|
||||
CONF_BC_PORT: host.api.baichuan.port,
|
||||
CONF_BC_ONLY: host.api.baichuan_only,
|
||||
CONF_BC_CONNECT: host.api.baichuan.connection_type.value,
|
||||
CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"),
|
||||
}
|
||||
hass.config_entries.async_update_entry(config_entry, data=data)
|
||||
|
||||
@@ -37,6 +37,7 @@ from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import (
|
||||
CONF_BC_CONNECT,
|
||||
CONF_BC_ONLY,
|
||||
CONF_BC_PORT,
|
||||
CONF_SUPPORTS_PRIVACY_MODE,
|
||||
@@ -310,6 +311,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_USE_HTTPS] = host.api.use_https
|
||||
user_input[CONF_BC_PORT] = host.api.baichuan.port
|
||||
user_input[CONF_BC_ONLY] = host.api.baichuan_only
|
||||
user_input[CONF_BC_CONNECT] = host.api.baichuan.connection_type.value
|
||||
user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported(
|
||||
None, "privacy_mode"
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ DOMAIN = "reolink"
|
||||
CONF_USE_HTTPS = "use_https"
|
||||
CONF_BC_PORT = "baichuan_port"
|
||||
CONF_BC_ONLY = "baichuan_only"
|
||||
CONF_BC_CONNECT = "baichuan_connection"
|
||||
CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported"
|
||||
CONF_FIRMWARE_CHECK_TIME = "firmware_check_time"
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import aiohttp
|
||||
from aiohttp.web import Request
|
||||
from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host
|
||||
from reolink_aio.baichuan import DEFAULT_BC_PORT
|
||||
from reolink_aio.enums import SubType
|
||||
from reolink_aio.enums import ConnectionEnum, SubType
|
||||
from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError
|
||||
|
||||
from homeassistant.components import webhook
|
||||
@@ -36,6 +36,7 @@ from .const import (
|
||||
BATTERY_ALL_WAKE_UPDATE_INTERVAL,
|
||||
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
|
||||
BATTERY_WAKE_UPDATE_INTERVAL,
|
||||
CONF_BC_CONNECT,
|
||||
CONF_BC_ONLY,
|
||||
CONF_BC_PORT,
|
||||
CONF_SUPPORTS_PRIVACY_MODE,
|
||||
@@ -77,6 +78,12 @@ class ReolinkHost:
|
||||
self._config_entry = config_entry
|
||||
self._config = config
|
||||
self._unique_id: str = ""
|
||||
try:
|
||||
bc_connection = ConnectionEnum(
|
||||
config.get(CONF_BC_CONNECT, ConnectionEnum.unknown.value)
|
||||
)
|
||||
except ValueError:
|
||||
bc_connection = ConnectionEnum.unknown
|
||||
|
||||
def get_aiohttp_session() -> aiohttp.ClientSession:
|
||||
"""Return the HA aiohttp session."""
|
||||
@@ -96,6 +103,7 @@ class ReolinkHost:
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
aiohttp_get_session_callback=get_aiohttp_session,
|
||||
bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT),
|
||||
bc_connection=bc_connection,
|
||||
bc_only=config.get(CONF_BC_ONLY, False),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -217,9 +217,9 @@
|
||||
"home": "[%key:common::entity::button::home::name%]",
|
||||
"insert": "Insert",
|
||||
"left": "[%key:common::entity::button::left::name%]",
|
||||
"lights_kbd_down": "Keyboasrd backlight brightness down",
|
||||
"lights_kbd_down": "Keyboard backlight brightness down",
|
||||
"lights_kbd_toggle": "Toggle keyboard backlight",
|
||||
"lights_kbd_up": "Keyboard backlight brighness up",
|
||||
"lights_kbd_up": "Keyboard backlight brightness up",
|
||||
"lights_mon_down": "Display brightness down",
|
||||
"lights_mon_up": "Display brightness up",
|
||||
"numpad_0": "NumPad 0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.components.blueprint import (
|
||||
)
|
||||
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
|
||||
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
|
||||
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
|
||||
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
|
||||
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
|
||||
from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN
|
||||
@@ -59,6 +60,7 @@ from . import (
|
||||
binary_sensor as binary_sensor_platform,
|
||||
button as button_platform,
|
||||
cover as cover_platform,
|
||||
device_tracker as device_tracker_platform,
|
||||
event as event_platform,
|
||||
fan as fan_platform,
|
||||
image as image_platform,
|
||||
@@ -199,6 +201,9 @@ CONFIG_SECTION_SCHEMA = vol.All(
|
||||
vol.Optional(COVER_DOMAIN): vol.All(
|
||||
cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA]
|
||||
),
|
||||
vol.Optional(DEVICE_TRACKER_DOMAIN): vol.All(
|
||||
cv.ensure_list, [device_tracker_platform.TRACKER_YAML_SCHEMA]
|
||||
),
|
||||
vol.Optional(EVENT_DOMAIN): vol.All(
|
||||
cv.ensure_list, [event_platform.EVENT_YAML_SCHEMA]
|
||||
),
|
||||
|
||||
@@ -23,6 +23,8 @@ from homeassistant.components.update import UpdateDeviceClass
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_NAME,
|
||||
CONF_STATE,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
@@ -74,6 +76,11 @@ from .cover import (
|
||||
STOP_ACTION,
|
||||
async_create_preview_cover,
|
||||
)
|
||||
from .device_tracker import (
|
||||
CONF_IN_ZONES,
|
||||
CONF_LOCATION_ACCURACY,
|
||||
async_create_preview_tracker,
|
||||
)
|
||||
from .event import CONF_EVENT_TYPE, CONF_EVENT_TYPES, async_create_preview_event
|
||||
from .fan import (
|
||||
CONF_OFF_ACTION,
|
||||
@@ -150,6 +157,7 @@ _SCHEMA_STATE: dict[vol.Marker, Any] = {
|
||||
def generate_schema(domain: str, flow_type: str) -> vol.Schema:
|
||||
"""Generate schema."""
|
||||
schema: dict[vol.Marker, Any] = {}
|
||||
advanced_options: dict[vol.Marker, Any] = {}
|
||||
|
||||
if flow_type == "config":
|
||||
schema = {vol.Required(CONF_NAME): selector.TextSelector()}
|
||||
@@ -226,6 +234,16 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
|
||||
)
|
||||
}
|
||||
|
||||
if domain == Platform.DEVICE_TRACKER:
|
||||
schema |= {
|
||||
vol.Optional(CONF_IN_ZONES): selector.TemplateSelector(),
|
||||
vol.Optional(CONF_LATITUDE): selector.TemplateSelector(),
|
||||
vol.Optional(CONF_LONGITUDE): selector.TemplateSelector(),
|
||||
}
|
||||
advanced_options |= {
|
||||
vol.Optional(CONF_LOCATION_ACCURACY): selector.TemplateSelector(),
|
||||
}
|
||||
|
||||
if domain == Platform.EVENT:
|
||||
schema |= {
|
||||
vol.Required(CONF_EVENT_TYPE): selector.TemplateSelector(),
|
||||
@@ -431,6 +449,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(),
|
||||
**advanced_options,
|
||||
}
|
||||
),
|
||||
{"collapsed": True},
|
||||
@@ -540,6 +559,7 @@ TEMPLATE_TYPES = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.COVER,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.EVENT,
|
||||
Platform.FAN,
|
||||
Platform.IMAGE,
|
||||
@@ -575,6 +595,11 @@ CONFIG_FLOW = {
|
||||
preview="template",
|
||||
validate_user_input=validate_user_input(Platform.COVER),
|
||||
),
|
||||
Platform.DEVICE_TRACKER: SchemaFlowFormStep(
|
||||
config_schema(Platform.DEVICE_TRACKER),
|
||||
preview="template",
|
||||
validate_user_input=validate_user_input(Platform.DEVICE_TRACKER),
|
||||
),
|
||||
Platform.EVENT: SchemaFlowFormStep(
|
||||
config_schema(Platform.EVENT),
|
||||
preview="template",
|
||||
@@ -660,6 +685,11 @@ OPTIONS_FLOW = {
|
||||
preview="template",
|
||||
validate_user_input=validate_user_input(Platform.COVER),
|
||||
),
|
||||
Platform.DEVICE_TRACKER: SchemaFlowFormStep(
|
||||
options_schema(Platform.DEVICE_TRACKER),
|
||||
preview="template",
|
||||
validate_user_input=validate_user_input(Platform.DEVICE_TRACKER),
|
||||
),
|
||||
Platform.EVENT: SchemaFlowFormStep(
|
||||
options_schema(Platform.EVENT),
|
||||
preview="template",
|
||||
@@ -730,6 +760,7 @@ CREATE_PREVIEW_ENTITY: dict[
|
||||
Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel,
|
||||
Platform.BINARY_SENSOR: async_create_preview_binary_sensor,
|
||||
Platform.COVER: async_create_preview_cover,
|
||||
Platform.DEVICE_TRACKER: async_create_preview_tracker,
|
||||
Platform.EVENT: async_create_preview_event,
|
||||
Platform.FAN: async_create_preview_fan,
|
||||
Platform.LIGHT: async_create_preview_light,
|
||||
|
||||
@@ -26,6 +26,7 @@ PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.COVER,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.EVENT,
|
||||
Platform.FAN,
|
||||
Platform.IMAGE,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Support for device trackers which integrates with other components."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import zone
|
||||
from homeassistant.components.device_tracker import (
|
||||
DOMAIN as DEVICE_TRACKER_DOMAIN,
|
||||
ENTITY_ID_FORMAT,
|
||||
TrackerEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import TriggerUpdateCoordinator, validators as template_validators
|
||||
from .entity import AbstractTemplateEntity
|
||||
from .helpers import (
|
||||
async_setup_template_entry,
|
||||
async_setup_template_platform,
|
||||
async_setup_template_preview,
|
||||
)
|
||||
from .schemas import (
|
||||
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
|
||||
make_template_entity_common_modern_schema,
|
||||
)
|
||||
from .template_entity import TemplateEntity
|
||||
from .trigger_entity import TriggerEntity
|
||||
|
||||
DEFAULT_NAME = "Template Device Tracker"
|
||||
|
||||
CONF_IN_ZONES = "in_zones"
|
||||
CONF_LOCATION_ACCURACY = "location_accuracy"
|
||||
|
||||
|
||||
def _validate_in_zones_or_lat_and_lon(obj: dict) -> dict:
|
||||
if CONF_IN_ZONES not in obj:
|
||||
if CONF_LATITUDE not in obj or CONF_LONGITUDE not in obj:
|
||||
raise vol.Invalid(
|
||||
f"Either '{CONF_IN_ZONES}' or both '{CONF_LATITUDE}' and '{CONF_LONGITUDE}' must be specified"
|
||||
)
|
||||
elif (CONF_LATITUDE in obj and CONF_LONGITUDE not in obj) or (
|
||||
CONF_LATITUDE not in obj and CONF_LONGITUDE in obj
|
||||
):
|
||||
raise vol.Invalid(
|
||||
f"Both '{CONF_LATITUDE}' and '{CONF_LONGITUDE}' must be specified"
|
||||
)
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def validate_in_zones(
|
||||
entity: AbstractTemplateTracker,
|
||||
) -> Callable[[Any], list[str] | None]:
|
||||
"""Convert the result to a list of entity_ids.
|
||||
|
||||
This ensures the result is a list of zone entity_ids.
|
||||
All other values that are not lists will result in None.
|
||||
"""
|
||||
|
||||
def convert(result: Any) -> list[str] | None:
|
||||
if template_validators.check_result_for_none(result):
|
||||
return None
|
||||
|
||||
if not isinstance(result, list):
|
||||
template_validators.log_validation_result_error(
|
||||
entity,
|
||||
CONF_IN_ZONES,
|
||||
result,
|
||||
"expected a list of zone entity_ids",
|
||||
)
|
||||
return None
|
||||
|
||||
zone_entity_ids = []
|
||||
failed = []
|
||||
for v in result:
|
||||
try:
|
||||
zone_entity_ids.append(
|
||||
vol.All(cv.entity_id, cv.entity_domain(zone.DOMAIN))(v)
|
||||
)
|
||||
except vol.Invalid:
|
||||
failed.append(v)
|
||||
|
||||
if failed:
|
||||
template_validators.log_validation_result_error(
|
||||
entity,
|
||||
CONF_IN_ZONES,
|
||||
failed,
|
||||
"expected a list of zone entity_ids",
|
||||
)
|
||||
|
||||
return zone_entity_ids
|
||||
|
||||
return convert
|
||||
|
||||
|
||||
TRACKER_COMMON_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_IN_ZONES): cv.template,
|
||||
vol.Optional(CONF_LATITUDE): cv.template,
|
||||
vol.Optional(CONF_LOCATION_ACCURACY): cv.template,
|
||||
vol.Optional(CONF_LONGITUDE): cv.template,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
TRACKER_YAML_SCHEMA = vol.All(
|
||||
_validate_in_zones_or_lat_and_lon,
|
||||
TRACKER_COMMON_SCHEMA.extend(
|
||||
make_template_entity_common_modern_schema(
|
||||
DEVICE_TRACKER_DOMAIN, DEFAULT_NAME
|
||||
).schema
|
||||
),
|
||||
)
|
||||
|
||||
TRACKER_CONFIG_ENTRY_SCHEMA = vol.All(
|
||||
_validate_in_zones_or_lat_and_lon,
|
||||
TRACKER_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the template device trackers."""
|
||||
await async_setup_template_platform(
|
||||
hass,
|
||||
DEVICE_TRACKER_DOMAIN,
|
||||
config,
|
||||
StateTrackerEntity,
|
||||
TriggerTrackerEntity,
|
||||
async_add_entities,
|
||||
discovery_info,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Initialize config entry."""
|
||||
await async_setup_template_entry(
|
||||
hass,
|
||||
config_entry,
|
||||
async_add_entities,
|
||||
StateTrackerEntity,
|
||||
TRACKER_CONFIG_ENTRY_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_create_preview_tracker(
|
||||
hass: HomeAssistant, name: str, config: dict[str, Any]
|
||||
) -> StateTrackerEntity:
|
||||
"""Create a preview device tracker."""
|
||||
return async_setup_template_preview(
|
||||
hass,
|
||||
name,
|
||||
config,
|
||||
StateTrackerEntity,
|
||||
TRACKER_CONFIG_ENTRY_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity):
|
||||
"""Representation of a template device tracker features."""
|
||||
|
||||
_entity_id_format = ENTITY_ID_FORMAT
|
||||
|
||||
# The super init is not called because TemplateEntity
|
||||
# and TriggerEntity will call
|
||||
# AbstractTemplateEntity.__init__. This ensures that
|
||||
# the __init__ on AbstractTemplateEntity is not
|
||||
# called twice.
|
||||
def __init__(self) -> None: # pylint: disable=super-init-not-called
|
||||
"""Initialize the features."""
|
||||
|
||||
self.setup_template(
|
||||
CONF_IN_ZONES,
|
||||
"_attr_in_zones",
|
||||
validate_in_zones(self),
|
||||
)
|
||||
self.setup_template(
|
||||
CONF_LATITUDE,
|
||||
"_attr_latitude",
|
||||
template_validators.number(self, CONF_LATITUDE, -90.0, 90.0),
|
||||
)
|
||||
self.setup_template(
|
||||
CONF_LONGITUDE,
|
||||
"_attr_longitude",
|
||||
template_validators.number(self, CONF_LONGITUDE, -180.0, 180.0),
|
||||
)
|
||||
self.setup_template(
|
||||
CONF_LOCATION_ACCURACY,
|
||||
"_attr_location_accuracy",
|
||||
on_update=self._update_location_accuracy,
|
||||
none_on_template_error=False,
|
||||
)
|
||||
|
||||
self._location_accuracy_validator = template_validators.number(
|
||||
self, CONF_LOCATION_ACCURACY, 0.0
|
||||
)
|
||||
|
||||
def _update_location_accuracy(self, value: float | None) -> None:
|
||||
"""Update the location accuracy."""
|
||||
self._attr_location_accuracy = self._location_accuracy_validator(value) or 0.0
|
||||
|
||||
|
||||
class StateTrackerEntity(TemplateEntity, AbstractTemplateTracker):
|
||||
"""Representation of a Template device tracker."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
unique_id: str | None,
|
||||
) -> None:
|
||||
"""Initialize the Template device tracker."""
|
||||
TemplateEntity.__init__(self, hass, config, unique_id)
|
||||
AbstractTemplateTracker.__init__(self)
|
||||
|
||||
|
||||
class TriggerTrackerEntity(TriggerEntity, AbstractTemplateTracker):
|
||||
"""Tracker entity based on trigger data."""
|
||||
|
||||
domain = DEVICE_TRACKER_DOMAIN
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
coordinator: TriggerUpdateCoordinator,
|
||||
config: ConfigType,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
TriggerEntity.__init__(self, hass, coordinator, config)
|
||||
AbstractTemplateTracker.__init__(self)
|
||||
@@ -136,6 +136,34 @@
|
||||
},
|
||||
"title": "Template cover"
|
||||
},
|
||||
"device_tracker": {
|
||||
"data": {
|
||||
"device_id": "[%key:common::config_flow::data::device%]",
|
||||
"latitude": "Latitude",
|
||||
"longitude": "Longitude",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"device_id": "[%key:component::template::common::device_id_description%]",
|
||||
"latitude": "Defines a template to get the latitude of the device tracker. Valid values are numbers between `-90` and `90`.",
|
||||
"longitude": "Defines a template to get the longitude of the device tracker. Valid values are numbers between `-180` and `180`.",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"sections": {
|
||||
"advanced_options": {
|
||||
"data": {
|
||||
"availability": "[%key:component::template::common::availability%]",
|
||||
"location_accuracy": "Location accuracy"
|
||||
},
|
||||
"data_description": {
|
||||
"availability": "[%key:component::template::common::availability_description%]",
|
||||
"location_accuracy": "Defines a template to get the accuracy of the device tracker's location in meters. Valid values are numbers greater than or equal to `0`."
|
||||
},
|
||||
"name": "[%key:component::template::common::advanced_options%]"
|
||||
}
|
||||
},
|
||||
"title": "Template device tracker"
|
||||
},
|
||||
"event": {
|
||||
"data": {
|
||||
"device_class": "[%key:component::template::common::device_class%]",
|
||||
@@ -454,6 +482,7 @@
|
||||
"binary_sensor": "[%key:component::binary_sensor::title%]",
|
||||
"button": "[%key:component::button::title%]",
|
||||
"cover": "[%key:component::cover::title%]",
|
||||
"device_tracker": "[%key:component::device_tracker::title%]",
|
||||
"event": "[%key:component::event::title%]",
|
||||
"fan": "[%key:component::fan::title%]",
|
||||
"image": "[%key:component::image::title%]",
|
||||
@@ -651,7 +680,6 @@
|
||||
},
|
||||
"title": "[%key:component::template::config::step::button::title%]"
|
||||
},
|
||||
|
||||
"cover": {
|
||||
"data": {
|
||||
"close_cover": "[%key:component::template::config::step::cover::data::close_cover%]",
|
||||
@@ -684,6 +712,32 @@
|
||||
},
|
||||
"title": "[%key:component::template::config::step::cover::title%]"
|
||||
},
|
||||
"device_tracker": {
|
||||
"data": {
|
||||
"device_id": "[%key:common::config_flow::data::device%]",
|
||||
"latitude": "[%key:component::template::config::step::device_tracker::data::latitude%]",
|
||||
"longitude": "[%key:component::template::config::step::device_tracker::data::longitude%]"
|
||||
},
|
||||
"data_description": {
|
||||
"device_id": "[%key:component::template::common::device_id_description%]",
|
||||
"latitude": "[%key:component::template::config::step::device_tracker::data_description::latitude%]",
|
||||
"longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]"
|
||||
},
|
||||
"sections": {
|
||||
"advanced_options": {
|
||||
"data": {
|
||||
"availability": "[%key:component::template::common::availability%]",
|
||||
"location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data::location_accuracy%]"
|
||||
},
|
||||
"data_description": {
|
||||
"availability": "[%key:component::template::common::availability_description%]",
|
||||
"location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data_description::location_accuracy%]"
|
||||
},
|
||||
"name": "[%key:component::template::common::advanced_options%]"
|
||||
}
|
||||
},
|
||||
"title": "[%key:component::template::config::step::device_tracker::title%]"
|
||||
},
|
||||
"event": {
|
||||
"data": {
|
||||
"device_id": "[%key:common::config_flow::data::device%]",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_NAME, UnitOfTime
|
||||
from homeassistant.helpers import selector
|
||||
@@ -27,6 +28,8 @@ from .const import (
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
ALLOWED_DOMAINS = [COUNTER_DOMAIN, SENSOR_DOMAIN]
|
||||
|
||||
|
||||
async def get_base_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
|
||||
"""Get base options schema."""
|
||||
@@ -90,7 +93,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): selector.TextSelector(),
|
||||
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain=SENSOR_DOMAIN, multiple=False),
|
||||
selector.EntitySelectorConfig(domain=ALLOWED_DOMAINS, multiple=False),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"domain": "trend",
|
||||
"name": "Trend",
|
||||
"after_dependencies": ["sensor", "counter"],
|
||||
"codeowners": ["@jpbede"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/trend",
|
||||
|
||||
@@ -202,9 +202,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
|
||||
# Determine preset modes (ignore if empty options)
|
||||
if definition.preset_wrapper and definition.preset_wrapper.options:
|
||||
self._attr_hvac_modes.append(description.switch_only_hvac_mode)
|
||||
self._attr_preset_modes = definition.preset_wrapper.options
|
||||
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
|
||||
if description.switch_only_hvac_mode not in self._attr_hvac_modes:
|
||||
self._attr_hvac_modes.append(description.switch_only_hvac_mode)
|
||||
|
||||
# Determine dpcode to use for setting the humidity
|
||||
if definition.target_humidity_wrapper:
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["tuya_sharing"],
|
||||
"requirements": [
|
||||
"tuya-device-handlers==0.0.19",
|
||||
"tuya-device-handlers==0.0.21",
|
||||
"tuya-device-sharing-sdk==0.2.8"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyvlx"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyvlx==0.2.34"]
|
||||
"requirements": ["pyvlx==0.2.35"]
|
||||
}
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyvesync"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyvesync==3.4.1"]
|
||||
"requirements": ["pyvesync==3.4.2"]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||
EntityNumericalStateChangedTriggerWithUnitBase,
|
||||
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
|
||||
EntityNumericalStateTriggerWithUnitBase,
|
||||
@@ -28,14 +28,16 @@ from .const import DOMAIN
|
||||
|
||||
CONF_OPERATION_MODE = "operation_mode"
|
||||
|
||||
_OPERATION_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_OPERATION_MODE): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [str]
|
||||
),
|
||||
},
|
||||
}
|
||||
_OPERATION_MODE_CHANGED_TRIGGER_SCHEMA = (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_OPERATION_MODE): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [str]
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "yardian",
|
||||
"name": "Yardian",
|
||||
"codeowners": ["@h3l1o5"],
|
||||
"codeowners": ["@aeon-matrix"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/yardian",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -91,7 +91,6 @@ CONF_COMMAND_ON: Final = "command_on"
|
||||
CONF_COMMAND_OPEN: Final = "command_open"
|
||||
CONF_COMMAND_STATE: Final = "command_state"
|
||||
CONF_COMMAND_STOP: Final = "command_stop"
|
||||
CONF_COMMENT: Final = "comment"
|
||||
CONF_CONDITION: Final = "condition"
|
||||
CONF_CONDITIONS: Final = "conditions"
|
||||
CONF_CONTINUE_ON_ERROR: Final = "continue_on_error"
|
||||
@@ -171,6 +170,7 @@ CONF_MODEL_ID: Final = "model_id"
|
||||
CONF_MONITORED_CONDITIONS: Final = "monitored_conditions"
|
||||
CONF_MONITORED_VARIABLES: Final = "monitored_variables"
|
||||
CONF_NAME: Final = "name"
|
||||
CONF_NOTE: Final = "note"
|
||||
CONF_OFFSET: Final = "offset"
|
||||
CONF_OPTIMISTIC: Final = "optimistic"
|
||||
CONF_OPTIONS: Final = "options"
|
||||
|
||||
@@ -118,7 +118,6 @@ class AbortFlow(FlowError):
|
||||
class FlowContext(TypedDict, total=False):
|
||||
"""Typed context dict."""
|
||||
|
||||
show_advanced_options: bool
|
||||
source: str
|
||||
|
||||
|
||||
|
||||
Generated
+1
@@ -541,6 +541,7 @@ FLOWS = {
|
||||
"opentherm_gw",
|
||||
"openuv",
|
||||
"openweathermap",
|
||||
"opnsense",
|
||||
"opower",
|
||||
"oralb",
|
||||
"orvibo",
|
||||
|
||||
@@ -5119,7 +5119,7 @@
|
||||
"opnsense": {
|
||||
"name": "OPNsense",
|
||||
"integration_type": "hub",
|
||||
"config_flow": false,
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"opower": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user