mirror of
https://github.com/home-assistant/core.git
synced 2026-05-23 17:25:10 +02:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1148ed285d | |||
| 8d456cb24f | |||
| 5ebd95eb34 | |||
| 228d7189c3 | |||
| a8e141a48a | |||
| d42d52a0f7 | |||
| cee0fe071d | |||
| e3593c3076 | |||
| 5498de07ff | |||
| ac3f973d7d | |||
| 6b8a2a4032 | |||
| 74e40af4bb | |||
| 833e15d6f2 | |||
| ee56fd1eb0 | |||
| e6528bae8a | |||
| a17eb65498 | |||
| 912a839d66 | |||
| 4306863729 | |||
| ba2f66e751 | |||
| 94581d8ab6 | |||
| 7d6ec7fc58 | |||
| f49de3548e | |||
| 49ab42d3a2 | |||
| 383f6142f0 | |||
| 2f120cf604 | |||
| 37288849b3 | |||
| aa8659f507 | |||
| 40c0d79d1d | |||
| bef8632d78 | |||
| f00decfaa3 | |||
| 42e7add026 | |||
| 263aa3f16e | |||
| 03b364dcf0 | |||
| 3b1aaf39af | |||
| b82ba43fa4 | |||
| d81ef5593c | |||
| 5c5e50f024 | |||
| e796d9c467 | |||
| 342f23526f | |||
| 814ec697cf | |||
| 120f1446d4 | |||
| 170af75b7d | |||
| 5432d29489 | |||
| 8098f4f6bc | |||
| 6a70077687 | |||
| 5dbb0464ba | |||
| 1df165ea02 | |||
| 62542eb911 | |||
| a842cac34c | |||
| 2460f688e3 | |||
| a868ea443c | |||
| 1d8565483b | |||
| 1ef3301253 | |||
| 525952f016 | |||
| 3257275c5a | |||
| cb54fd4921 | |||
| b391fc61ea | |||
| fcd4e4939c | |||
| deb8b5da05 | |||
| c7754a6ce9 | |||
| 242724bd50 | |||
| 42454563db | |||
| bf03d0c216 | |||
| 568107e06b | |||
| 7da44428b6 | |||
| 0a27f31949 | |||
| 905b868c82 | |||
| 3187289913 | |||
| 87cecd4a44 | |||
| fed38b0e38 | |||
| 6a36d1260b | |||
| 49fc1b413d | |||
| bffb0417cc | |||
| 8b8c687fc3 |
@@ -15,6 +15,7 @@ Dockerfile.dev linguist-language=Dockerfile
|
||||
# Generated files
|
||||
CODEOWNERS linguist-generated=true
|
||||
homeassistant/generated/*.py linguist-generated=true
|
||||
pylint/plugins/pylint_home_assistant/generated/*.py linguist-generated=true
|
||||
machine/* linguist-generated=true
|
||||
mypy.ini linguist-generated=true
|
||||
requirements.txt linguist-generated=true
|
||||
|
||||
@@ -25,6 +25,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ jobs:
|
||||
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
|
||||
echo "::add-matcher::.github/workflows/matchers/codespell.json"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
|
||||
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
||||
env:
|
||||
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
@@ -302,7 +302,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
|
||||
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
||||
with:
|
||||
extra-args: --all-files zizmor
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
exclude_types: [csv, json, html]
|
||||
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.24.1
|
||||
rev: v1.25.2
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
|
||||
@@ -15,6 +15,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
|
||||
Generated
-2
@@ -945,8 +945,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/knx/ @Julius2342 @farmio @marvin-w
|
||||
/homeassistant/components/kodi/ @OnFreund
|
||||
/tests/components/kodi/ @OnFreund
|
||||
/homeassistant/components/konnected/ @heythisisnate
|
||||
/tests/components/konnected/ @heythisisnate
|
||||
/homeassistant/components/kostal_plenticore/ @stegm
|
||||
/tests/components/kostal_plenticore/ @stegm
|
||||
/homeassistant/components/kraken/ @eifinger
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"service": "mdi:dialpad"
|
||||
},
|
||||
"alarm_toggle_chime": {
|
||||
"service": "mdi:abc"
|
||||
"service": "mdi:bell-ring"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CLOUD_NEVER_EXPOSED_ENTITIES,
|
||||
CONF_DESCRIPTION,
|
||||
CONF_NAME,
|
||||
UnitOfTemperature,
|
||||
@@ -373,9 +372,6 @@ def async_get_entities(
|
||||
"""Return all entities that are supported by Alexa."""
|
||||
entities: list[AlexaEntity] = []
|
||||
for state in hass.states.async_all():
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
continue
|
||||
|
||||
if state.domain not in ENTITY_ADAPTERS:
|
||||
continue
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotAuthenticate as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
|
||||
@@ -102,6 +102,9 @@
|
||||
"entry_not_loaded": {
|
||||
"message": "Entry not loaded: {entry}"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Invalid authentication credentials: {error}"
|
||||
},
|
||||
"invalid_device_id": {
|
||||
"message": "Invalid device ID specified: {device_id}"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,13 @@ from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceIn
|
||||
|
||||
from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
|
||||
|
||||
STEP_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow."""
|
||||
@@ -31,13 +38,22 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(uuid)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
async def _async_try_connect(self, host: str, port: int) -> None:
|
||||
"""Verify the device is reachable."""
|
||||
async def _async_try_connect(self, host: str, port: int) -> dict[str, str]:
|
||||
"""Verify the device is reachable; return errors keyed by reason."""
|
||||
client = Client(host, port)
|
||||
try:
|
||||
await client.start()
|
||||
except socket.gaierror:
|
||||
return {"base": "invalid_host"}
|
||||
except TimeoutError:
|
||||
return {"base": "timeout_connect"}
|
||||
except ConnectionRefusedError:
|
||||
return {"base": "connection_refused"}
|
||||
except ConnectionFailed, OSError:
|
||||
return {"base": "cannot_connect"}
|
||||
finally:
|
||||
await client.stop()
|
||||
return {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -53,19 +69,10 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_HOST], user_input[CONF_PORT], uuid
|
||||
)
|
||||
|
||||
try:
|
||||
await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
except socket.gaierror:
|
||||
errors["base"] = "invalid_host"
|
||||
except TimeoutError:
|
||||
errors["base"] = "timeout_connect"
|
||||
except ConnectionRefusedError:
|
||||
errors["base"] = "connection_refused"
|
||||
except ConnectionFailed, OSError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
errors = await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})",
|
||||
data={
|
||||
@@ -74,16 +81,46 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
fields = {
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
}
|
||||
schema = vol.Schema(fields)
|
||||
schema = STEP_DATA_SCHEMA
|
||||
if user_input is not None:
|
||||
schema = self.add_suggested_values_to_schema(schema, user_input)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of an existing entry."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if user_input is not None:
|
||||
uuid = await get_uniqueid_from_host(
|
||||
async_get_clientsession(self.hass), user_input[CONF_HOST]
|
||||
)
|
||||
if uuid:
|
||||
await self.async_set_unique_id(uuid)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
errors = await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
},
|
||||
)
|
||||
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
STEP_DATA_SCHEMA, user_input or reconfigure_entry.data
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -113,9 +150,7 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
await self._async_set_unique_id_and_update(host, port, uuid)
|
||||
|
||||
try:
|
||||
await self._async_try_connect(host, port)
|
||||
except ConnectionFailed, OSError:
|
||||
if await self._async_try_connect(host, port):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self.host = host
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"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%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -16,6 +18,13 @@
|
||||
"confirm": {
|
||||
"description": "Do you want to add Arcam FMJ on `{host}` to Home Assistant?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"description": "[%key:component::arcam_fmj::config::step::user::description%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.29.11",
|
||||
"dbus-fast==5.0.0",
|
||||
"habluetooth==6.1.0"
|
||||
"dbus-fast==5.0.3",
|
||||
"habluetooth==6.4.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import Event, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, start
|
||||
@@ -275,9 +274,6 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
|
||||
def _should_expose_legacy(self, entity_id: str) -> bool:
|
||||
"""If an entity should be exposed."""
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_configs = self._prefs.alexa_entity_configs
|
||||
entity_config = entity_configs.get(entity_id, {})
|
||||
entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
|
||||
@@ -308,8 +304,6 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
"""If an entity should be exposed."""
|
||||
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
||||
if not entity_filter.empty_filter:
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
return entity_filter(entity_id)
|
||||
|
||||
return async_should_expose(self.hass, CLOUD_ALEXA, entity_id)
|
||||
|
||||
@@ -22,7 +22,6 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import (
|
||||
CoreState,
|
||||
Event,
|
||||
@@ -282,9 +281,6 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
|
||||
def _should_expose_legacy(self, entity_id: str) -> bool:
|
||||
"""If an entity ID should be exposed."""
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_configs = self._prefs.google_entity_configs
|
||||
entity_config = entity_configs.get(entity_id, {})
|
||||
entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
|
||||
@@ -316,8 +312,6 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
"""If an entity should be exposed."""
|
||||
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
||||
if not entity_filter.empty_filter:
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
return entity_filter(entity_id)
|
||||
|
||||
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
|
||||
|
||||
@@ -29,7 +29,6 @@ from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.components.system_health import get_info as get_system_health_info
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -973,7 +972,7 @@ async def google_assistant_get(
|
||||
return
|
||||
|
||||
entity = google_helpers.GoogleEntity(hass, gconf, state)
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported():
|
||||
if not entity.is_supported():
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_SUPPORTED,
|
||||
@@ -1075,9 +1074,7 @@ async def alexa_get(
|
||||
"""Get data for a single alexa entity."""
|
||||
entity_id: str = msg["entity_id"]
|
||||
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa(
|
||||
hass, entity_id
|
||||
):
|
||||
if not entity_supported_by_alexa(hass, entity_id):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_SUPPORTED,
|
||||
|
||||
@@ -65,11 +65,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except aiocomelit_exceptions.CannotAuthenticate as err:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise InvalidAuth(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_authenticate",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
finally:
|
||||
await api.logout()
|
||||
|
||||
@@ -61,7 +61,7 @@ class CurrencylayerSensor(SensorEntity):
|
||||
"""Implementing the Currencylayer sensor."""
|
||||
|
||||
_attr_attribution = "Data provided by currencylayer.com"
|
||||
_attr_icon = "mdi:currency"
|
||||
_attr_icon = "mdi:currency-usd"
|
||||
|
||||
def __init__(self, rest: CurrencylayerData, base: str, quote: str) -> None:
|
||||
"""Initialize the sensor."""
|
||||
|
||||
@@ -26,12 +26,12 @@ from homeassistant.components.climate import (
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.const import ATTR_LOCKED, ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DeconzConfigEntry
|
||||
from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE
|
||||
from .const import ATTR_OFFSET, ATTR_VALVE
|
||||
from .entity import DeconzDevice
|
||||
from .hub import DeconzHub
|
||||
|
||||
|
||||
@@ -43,8 +43,6 @@ PLATFORMS = [
|
||||
]
|
||||
|
||||
ATTR_DARK = "dark"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_LOCKED = "locked"
|
||||
ATTR_OFFSET = "offset"
|
||||
ATTR_ON = "on"
|
||||
ATTR_VALVE = "valve"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.1",
|
||||
"aiodiscover==3.2.0",
|
||||
"aiodiscover==3.2.3",
|
||||
"cached-ipaddress==1.0.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
|
||||
import aiodns
|
||||
from aiodns.error import DNSError
|
||||
from pycares import AresError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PORT
|
||||
@@ -78,11 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
|
||||
) from err
|
||||
|
||||
errors = [
|
||||
result
|
||||
for result in results
|
||||
if isinstance(
|
||||
result, (TimeoutError, DNSError, AresError, asyncio.CancelledError)
|
||||
)
|
||||
result for result in results if isinstance(result, (TimeoutError, DNSError))
|
||||
]
|
||||
if errors and len(errors) == len(results):
|
||||
await _close_resolvers()
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.0.4",
|
||||
"aioesphomeapi==45.1.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.3"
|
||||
"bleak-esphome==3.8.1"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"service": "mdi:refresh"
|
||||
},
|
||||
"set_dhw_override": {
|
||||
"service": "mdi:water-heater"
|
||||
"service": "mdi:water-boiler"
|
||||
},
|
||||
"set_system_mode": {
|
||||
"service": "mdi:pencil"
|
||||
|
||||
@@ -16,7 +16,7 @@ class DeviceType(Enum):
|
||||
GAME_CONSOLE = "mdi:nintendo-game-boy"
|
||||
STREAMING_DONGLE = "mdi:cast"
|
||||
LOUDSPEAKER = SOUND_SYSTEM = STB = SATELLITE = MUSIC = "mdi:speaker"
|
||||
DISC_PLAYER = "mdi:disk-player"
|
||||
DISC_PLAYER = "mdi:disc-player"
|
||||
REMOTE_CONTROL = "mdi:remote-tv"
|
||||
RADIO = "mdi:radio"
|
||||
PHOTO_CAMERA = PHOTOS = "mdi:camera"
|
||||
|
||||
@@ -10,7 +10,6 @@ from .coordinator import FloDeviceDataUpdateCoordinator
|
||||
class FloEntity(Entity):
|
||||
"""A base class for Flo entities."""
|
||||
|
||||
_attr_force_update = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"entity": {
|
||||
"button": {
|
||||
"sync_clock": {
|
||||
"default": "mdi:clock-sync"
|
||||
"default": "mdi:clock-check"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
|
||||
@@ -18,7 +18,6 @@ from homeassistant.components import webhook
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CLOUD_NEVER_EXPOSED_ENTITIES,
|
||||
CONF_NAME,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
@@ -803,8 +802,6 @@ def async_get_entities(
|
||||
is_supported_cache = config.is_supported_cache
|
||||
for state in hass.states.async_all():
|
||||
entity_id = state.entity_id
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
continue
|
||||
# Check check inlined for performance to avoid
|
||||
# function calls for every entity since we enumerate
|
||||
# the entire state machine here
|
||||
|
||||
@@ -12,7 +12,6 @@ import jwt
|
||||
|
||||
from homeassistant.components import webhook
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -167,9 +166,6 @@ class GoogleConfig(AbstractConfig):
|
||||
# Ignore entities that are views
|
||||
return False
|
||||
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
registry_entry = entity_registry.async_get(state.entity_id)
|
||||
if registry_entry:
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["guntamatic==1.8.0"]
|
||||
"requirements": ["guntamatic==1.9.0"]
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ async def _validate_auth(
|
||||
|
||||
def _get_current_hosts(entry: HeosConfigEntry) -> set[str]:
|
||||
"""Get a set of current hosts from the entry."""
|
||||
hosts = set(entry.data[CONF_HOST])
|
||||
hosts = {entry.data[CONF_HOST]}
|
||||
if hasattr(entry, "runtime_data"):
|
||||
hosts.update(
|
||||
player.ip_address
|
||||
|
||||
@@ -473,6 +473,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
|
||||
await self.coordinator.heos.set_group(new_members)
|
||||
return
|
||||
|
||||
@catch_action_error("remove from queue")
|
||||
async def async_remove_from_queue(self, queue_ids: list[int]) -> None:
|
||||
"""Remove items from the queue."""
|
||||
await self._player.remove_from_queue(queue_ids)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODE = "mode"
|
||||
ATTR_TIME_PERIOD = "time_period"
|
||||
ATTR_ONOFF = "on_off"
|
||||
CONF_CODE = "2fa"
|
||||
|
||||
@@ -12,12 +12,12 @@ from homeassistant.components.light import (
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.const import ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from . import HiveConfigEntry, refresh_system
|
||||
from .const import ATTR_MODE
|
||||
from .entity import HiveEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -6,12 +6,11 @@ from typing import Any
|
||||
from apyhiveapi import Hive
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.const import ATTR_MODE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HiveConfigEntry, refresh_system
|
||||
from .const import ATTR_MODE
|
||||
from .entity import HiveEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -10,7 +10,6 @@ import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -246,9 +245,6 @@ class ExposedEntities:
|
||||
"""Return True if an entity should be exposed to an assistant."""
|
||||
should_expose: bool
|
||||
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_registry = er.async_get(self._hass)
|
||||
if not (registry_entry := entity_registry.async_get(entity_id)):
|
||||
return self._async_should_expose_legacy_entity(assistant, entity_id)
|
||||
@@ -406,19 +402,6 @@ def ws_expose_entity(
|
||||
"""Expose an entity to an assistant."""
|
||||
entity_ids: list[str] = msg["entity_ids"]
|
||||
|
||||
if blocked := next(
|
||||
(
|
||||
entity_id
|
||||
for entity_id in entity_ids
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
),
|
||||
None,
|
||||
):
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.ERR_NOT_ALLOWED, f"can't expose '{blocked}'"
|
||||
)
|
||||
return
|
||||
|
||||
for entity_id in entity_ids:
|
||||
for assistant in msg["assistants"]:
|
||||
async_expose_entity(hass, assistant, entity_id, msg["should_expose"])
|
||||
|
||||
@@ -19,7 +19,9 @@ EXPECTED_ENTRY_VERSION = (
|
||||
@callback
|
||||
def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
|
||||
"""Return board info."""
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
entries = hass.config_entries.async_entries(
|
||||
DOMAIN, include_ignore=False, include_disabled=False
|
||||
)
|
||||
return [
|
||||
HardwareInfo(
|
||||
board=None,
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["homewizard_energy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-homewizard-energy==10.0.1"],
|
||||
"requirements": ["python-homewizard-energy==10.1.0"],
|
||||
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.variance import ignore_variance
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
|
||||
@@ -66,13 +65,6 @@ def to_percentage(value: float | None) -> float | None:
|
||||
return value * 100 if value is not None else None
|
||||
|
||||
|
||||
def uptime_to_datetime(value: int) -> datetime:
|
||||
"""Convert seconds to datetime timestamp."""
|
||||
return utcnow().replace(microsecond=0) - timedelta(seconds=value)
|
||||
|
||||
|
||||
uptime_to_stable_datetime = ignore_variance(uptime_to_datetime, timedelta(minutes=5))
|
||||
|
||||
SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="smr_version",
|
||||
@@ -643,7 +635,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="uptime",
|
||||
translation_key="uptime",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
device_class=SensorDeviceClass.UPTIME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
has_fn=(
|
||||
@@ -651,7 +643,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
),
|
||||
value_fn=(
|
||||
lambda data: (
|
||||
uptime_to_stable_datetime(data.system.uptime_s)
|
||||
utcnow() - timedelta(seconds=data.system.uptime_s)
|
||||
if data.system is not None and data.system.uptime_s is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -61,13 +61,14 @@
|
||||
},
|
||||
"select": {
|
||||
"battery_group_mode": {
|
||||
"name": "Battery group mode",
|
||||
"name": "Battery group charging strategy",
|
||||
"state": {
|
||||
"predictive": "Smart charging",
|
||||
"standby": "Standby",
|
||||
"to_full": "Manual charge mode",
|
||||
"zero": "Zero mode",
|
||||
"zero_charge_only": "Zero mode (charge only)",
|
||||
"zero_discharge_only": "Zero mode (discharge only)"
|
||||
"to_full": "One-time full charge",
|
||||
"zero": "Net zero",
|
||||
"zero_charge_only": "Net zero (charge only)",
|
||||
"zero_discharge_only": "Net zero (discharge only)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -31,15 +31,16 @@ activate_scene:
|
||||
dynamic:
|
||||
selector:
|
||||
boolean:
|
||||
speed:
|
||||
advanced: true
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
brightness:
|
||||
advanced: true
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 255
|
||||
scene_customization:
|
||||
collapsed: true
|
||||
fields:
|
||||
speed:
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
brightness:
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 255
|
||||
|
||||
@@ -184,7 +184,12 @@
|
||||
"name": "Transition"
|
||||
}
|
||||
},
|
||||
"name": "Activate Hue scene"
|
||||
"name": "Activate Hue scene",
|
||||
"sections": {
|
||||
"scene_customization": {
|
||||
"name": "Scene customization"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hue_activate_scene": {
|
||||
"description": "Activates a Hue scene stored in the Hue hub.",
|
||||
|
||||
@@ -87,6 +87,8 @@ def async_get_triggers(
|
||||
|
||||
# Get Hue device id from device identifier
|
||||
hue_dev_id = get_hue_device_id(device_entry)
|
||||
if hue_dev_id is None or hue_dev_id not in api.devices:
|
||||
return []
|
||||
# extract triggers from all button resources of this Hue device
|
||||
triggers: list[dict[str, Any]] = []
|
||||
model_id = api.devices[hue_dev_id].product_data.product_name
|
||||
|
||||
@@ -118,6 +118,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
device = devices.add_x10_device(housecode, unitcode, x10_type, steps)
|
||||
|
||||
create_insteon_device(hass, devices.modem, entry.entry_id)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, INSTEON_PLATFORMS)
|
||||
|
||||
for address in devices:
|
||||
@@ -131,8 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
register_new_device_callback(hass)
|
||||
async_setup_services(hass)
|
||||
|
||||
create_insteon_device(hass, devices.modem, entry.entry_id)
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass, async_get_device_config(hass, entry), "insteon-get-device-config"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"erev_shabbat_hag": { "default": "mdi:candle-light" },
|
||||
"erev_shabbat_hag": { "default": "mdi:candle" },
|
||||
"issur_melacha_in_effect": { "default": "mdi:power-plug-off" },
|
||||
"motzei_shabbat_hag": { "default": "mdi:fire" }
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"service": "mdi:lock-open"
|
||||
},
|
||||
"disable": {
|
||||
"service": "mdi:fash-off"
|
||||
"service": "mdi:flash-off"
|
||||
},
|
||||
"enable": {
|
||||
"service": "mdi:flash"
|
||||
|
||||
@@ -1,450 +1,53 @@
|
||||
"""Support for Konnected devices."""
|
||||
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
"""The Konnected.io integration."""
|
||||
|
||||
import copy
|
||||
import hmac
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp.hdrs import AUTHORIZATION
|
||||
from aiohttp.web import Request, Response
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_DISCOVERY,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_PIN,
|
||||
CONF_PORT,
|
||||
CONF_REPEAT,
|
||||
CONF_SENSORS,
|
||||
CONF_SWITCHES,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .config_flow import ( # Loading the config flow file will register the flow
|
||||
CONF_DEFAULT_OPTIONS,
|
||||
CONF_IO,
|
||||
CONF_IO_BIN,
|
||||
CONF_IO_DIG,
|
||||
CONF_IO_SWI,
|
||||
OPTIONS_SCHEMA,
|
||||
)
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_API_HOST,
|
||||
CONF_BLINK,
|
||||
CONF_INVERSE,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
CONF_POLL_INTERVAL,
|
||||
DOMAIN,
|
||||
PIN_TO_ZONE,
|
||||
STATE_HIGH,
|
||||
STATE_LOW,
|
||||
UPDATE_ENDPOINT,
|
||||
ZONE_TO_PIN,
|
||||
ZONES,
|
||||
)
|
||||
from .handlers import HANDLERS
|
||||
from .panel import AlarmPanel
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_pin(value):
|
||||
"""Check if valid pin and coerce to string."""
|
||||
if value is None:
|
||||
raise vol.Invalid("pin value is None")
|
||||
|
||||
if PIN_TO_ZONE.get(str(value)) is None:
|
||||
raise vol.Invalid("pin not valid")
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def ensure_zone(value):
|
||||
"""Check if valid zone and coerce to string."""
|
||||
if value is None:
|
||||
raise vol.Invalid("zone value is None")
|
||||
|
||||
if str(value) not in ZONES:
|
||||
raise vol.Invalid("zone not valid")
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def import_device_validator(config):
|
||||
"""Validate zones and reformat for import."""
|
||||
config = copy.deepcopy(config)
|
||||
io_cfgs = {}
|
||||
# Replace pins with zones
|
||||
for conf_platform, conf_io in (
|
||||
(CONF_BINARY_SENSORS, CONF_IO_BIN),
|
||||
(CONF_SENSORS, CONF_IO_DIG),
|
||||
(CONF_SWITCHES, CONF_IO_SWI),
|
||||
):
|
||||
for zone in config.get(conf_platform, []):
|
||||
if zone.get(CONF_PIN):
|
||||
zone[CONF_ZONE] = PIN_TO_ZONE[zone[CONF_PIN]]
|
||||
del zone[CONF_PIN]
|
||||
io_cfgs[zone[CONF_ZONE]] = conf_io
|
||||
|
||||
# Migrate config_entry data into default_options structure
|
||||
config[CONF_IO] = io_cfgs
|
||||
config[CONF_DEFAULT_OPTIONS] = OPTIONS_SCHEMA(config)
|
||||
|
||||
# clean up fields migrated to options
|
||||
config.pop(CONF_BINARY_SENSORS, None)
|
||||
config.pop(CONF_SENSORS, None)
|
||||
config.pop(CONF_SWITCHES, None)
|
||||
config.pop(CONF_BLINK, None)
|
||||
config.pop(CONF_DISCOVERY, None)
|
||||
config.pop(CONF_API_HOST, None)
|
||||
config.pop(CONF_IO, None)
|
||||
return config
|
||||
|
||||
|
||||
def import_validator(config):
|
||||
"""Reformat for import."""
|
||||
config = copy.deepcopy(config)
|
||||
|
||||
# push api_host into device configs
|
||||
for device in config.get(CONF_DEVICES, []):
|
||||
device[CONF_API_HOST] = config.get(CONF_API_HOST, "")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# configuration.yaml schemas (legacy)
|
||||
BINARY_SENSOR_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
|
||||
vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
|
||||
vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_INVERSE, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
|
||||
)
|
||||
|
||||
SENSOR_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
|
||||
vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
|
||||
vol.Required(CONF_TYPE): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])),
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
|
||||
)
|
||||
|
||||
SWITCH_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
|
||||
vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All(
|
||||
vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)
|
||||
),
|
||||
vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_REPEAT): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
|
||||
)
|
||||
|
||||
DEVICE_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"),
|
||||
vol.Optional(CONF_BINARY_SENSORS): vol.All(
|
||||
cv.ensure_list, [BINARY_SENSOR_SCHEMA_YAML]
|
||||
),
|
||||
vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA_YAML]),
|
||||
vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA_YAML]),
|
||||
vol.Inclusive(CONF_HOST, "host_info"): cv.string,
|
||||
vol.Inclusive(CONF_PORT, "host_info"): cv.port,
|
||||
vol.Optional(CONF_BLINK, default=True): cv.boolean,
|
||||
vol.Optional(CONF_API_HOST, default=""): vol.Any("", cv.url),
|
||||
vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
|
||||
}
|
||||
),
|
||||
import_device_validator,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.All(
|
||||
import_validator,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCESS_TOKEN): cv.string,
|
||||
vol.Optional(CONF_API_HOST): vol.Url(),
|
||||
vol.Optional(CONF_DEVICES): vol.All(
|
||||
cv.ensure_list, [DEVICE_SCHEMA_YAML]
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
YAML_CONFIGS = "yaml_configs"
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
|
||||
CONFIG_SCHEMA = vol.Schema({DOMAIN: cv.match_all}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Konnected platform."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_firmware",
|
||||
breaks_in_ha_version="2026.4.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_firmware",
|
||||
translation_placeholders={
|
||||
"kb_page_url": "https://support.konnected.io/migrating-from-konnected-legacy-home-assistant-integration-to-esphome",
|
||||
},
|
||||
)
|
||||
if (cfg := config.get(DOMAIN)) is None:
|
||||
cfg = {}
|
||||
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {
|
||||
CONF_ACCESS_TOKEN: cfg.get(CONF_ACCESS_TOKEN),
|
||||
CONF_API_HOST: cfg.get(CONF_API_HOST),
|
||||
CONF_DEVICES: {},
|
||||
}
|
||||
|
||||
hass.http.register_view(KonnectedView)
|
||||
|
||||
# Check if they have yaml configured devices
|
||||
if CONF_DEVICES not in cfg:
|
||||
return True
|
||||
|
||||
for device in cfg.get(CONF_DEVICES, []):
|
||||
# Attempt to importing the cfg. Use
|
||||
# hass.async_add_job to avoid a deadlock.
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=device
|
||||
)
|
||||
)
|
||||
"""Set up the Konnected.io integration."""
|
||||
if DOMAIN in config:
|
||||
_create_issue(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up panel from a config entry."""
|
||||
client = AlarmPanel(hass, entry)
|
||||
# creates a panel data store in hass.data[DOMAIN][CONF_DEVICES]
|
||||
await client.async_save_data()
|
||||
|
||||
# if the cfg entry was created we know we could connect to the panel at some point
|
||||
# async_connect will handle retries until it establishes a connection
|
||||
await client.async_connect()
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_entry_updated))
|
||||
"""Set up Konnected.io from a config entry."""
|
||||
_create_issue(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if all(
|
||||
config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
for config_entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if config_entry.entry_id != entry.entry_id
|
||||
):
|
||||
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID])
|
||||
|
||||
return unload_ok
|
||||
return True
|
||||
|
||||
|
||||
async def async_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload the config entry when options change."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
class KonnectedView(HomeAssistantView):
|
||||
"""View creates an endpoint to receive push updates from the device."""
|
||||
|
||||
url = UPDATE_ENDPOINT
|
||||
name = "api:konnected"
|
||||
requires_auth = False # Uses access token from configuration
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the view."""
|
||||
|
||||
@staticmethod
|
||||
def binary_value(state, activation):
|
||||
"""Return binary value for GPIO based on state and activation."""
|
||||
if activation == STATE_HIGH:
|
||||
return 1 if state == STATE_ON else 0
|
||||
return 0 if state == STATE_ON else 1
|
||||
|
||||
async def update_sensor(self, request: Request, device_id) -> Response:
|
||||
"""Process a put or post."""
|
||||
hass = request.app[KEY_HASS]
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
auth = request.headers.get(AUTHORIZATION)
|
||||
tokens = []
|
||||
if hass.data[DOMAIN].get(CONF_ACCESS_TOKEN):
|
||||
tokens.extend([hass.data[DOMAIN][CONF_ACCESS_TOKEN]])
|
||||
tokens.extend(
|
||||
[
|
||||
entry.data[CONF_ACCESS_TOKEN]
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.data.get(CONF_ACCESS_TOKEN)
|
||||
]
|
||||
)
|
||||
if auth is None or not next(
|
||||
(True for token in tokens if hmac.compare_digest(f"Bearer {token}", auth)),
|
||||
False,
|
||||
):
|
||||
return self.json_message(
|
||||
"unauthorized", status_code=HTTPStatus.UNAUTHORIZED
|
||||
)
|
||||
|
||||
try: # Konnected 2.2.0 and above supports JSON payloads
|
||||
payload = await request.json()
|
||||
except json.decoder.JSONDecodeError:
|
||||
_LOGGER.error(
|
||||
"Your Konnected device software may be out of "
|
||||
"date. Visit https://help.konnected.io for "
|
||||
"updating instructions"
|
||||
)
|
||||
|
||||
if (device := data[CONF_DEVICES].get(device_id)) is None:
|
||||
return self.json_message(
|
||||
"unregistered device", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if (panel := device.get("panel")) is not None:
|
||||
# connect if we haven't already
|
||||
hass.async_create_task(panel.async_connect())
|
||||
|
||||
try:
|
||||
zone_num = str(payload.get(CONF_ZONE) or PIN_TO_ZONE[payload[CONF_PIN]])
|
||||
payload[CONF_ZONE] = zone_num
|
||||
zone_data = (
|
||||
device[CONF_BINARY_SENSORS].get(zone_num)
|
||||
or next(
|
||||
(s for s in device[CONF_SWITCHES] if s[CONF_ZONE] == zone_num), None
|
||||
)
|
||||
or next(
|
||||
(s for s in device[CONF_SENSORS] if s[CONF_ZONE] == zone_num), None
|
||||
)
|
||||
)
|
||||
except KeyError:
|
||||
zone_data = None
|
||||
|
||||
if zone_data is None:
|
||||
return self.json_message(
|
||||
"unregistered sensor/actuator", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
zone_data["device_id"] = device_id
|
||||
|
||||
for attr in ("state", "temp", "humi", "addr"):
|
||||
value = payload.get(attr)
|
||||
handler = HANDLERS.get(attr)
|
||||
if value is not None and handler:
|
||||
hass.async_create_task(handler(hass, zone_data, payload))
|
||||
|
||||
return self.json_message("ok")
|
||||
|
||||
async def get(self, request: Request, device_id) -> Response:
|
||||
"""Return the current binary state of a switch."""
|
||||
hass = request.app[KEY_HASS]
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
if not (device := data[CONF_DEVICES].get(device_id)):
|
||||
return self.json_message(
|
||||
f"Device {device_id} not configured", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if (panel := device.get("panel")) is not None:
|
||||
# connect if we haven't already
|
||||
hass.async_create_task(panel.async_connect())
|
||||
|
||||
# Our data model is based on zone ids but we convert from/to pin ids
|
||||
# based on whether they are specified in the request
|
||||
try:
|
||||
zone_num = str(
|
||||
request.query.get(CONF_ZONE) or PIN_TO_ZONE[request.query[CONF_PIN]]
|
||||
)
|
||||
zone = next(
|
||||
switch
|
||||
for switch in device[CONF_SWITCHES]
|
||||
if switch[CONF_ZONE] == zone_num
|
||||
)
|
||||
|
||||
except StopIteration:
|
||||
zone = None
|
||||
except KeyError:
|
||||
zone = None
|
||||
zone_num = None
|
||||
|
||||
if not zone:
|
||||
target = request.query.get(
|
||||
CONF_ZONE, request.query.get(CONF_PIN, "unknown")
|
||||
)
|
||||
return self.json_message(
|
||||
f"Switch on zone or pin {target} not configured",
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
resp = {}
|
||||
if request.query.get(CONF_ZONE):
|
||||
resp[CONF_ZONE] = zone_num
|
||||
elif zone_num:
|
||||
resp[CONF_PIN] = ZONE_TO_PIN[zone_num]
|
||||
|
||||
# Make sure entity is setup
|
||||
if zone_entity_id := zone.get(ATTR_ENTITY_ID):
|
||||
resp["state"] = self.binary_value(
|
||||
hass.states.get(zone_entity_id).state, # type: ignore[union-attr]
|
||||
zone[CONF_ACTIVATION],
|
||||
)
|
||||
return self.json(resp)
|
||||
|
||||
_LOGGER.warning("Konnected entity not yet setup, returning default")
|
||||
resp["state"] = self.binary_value(STATE_OFF, zone[CONF_ACTIVATION])
|
||||
return self.json(resp)
|
||||
|
||||
async def put(self, request: Request, device_id) -> Response:
|
||||
"""Receive a sensor update via PUT request and async set state."""
|
||||
return await self.update_sensor(request, device_id)
|
||||
|
||||
async def post(self, request: Request, device_id) -> Response:
|
||||
"""Receive a sensor update via POST request and async set state."""
|
||||
return await self.update_sensor(request, device_id)
|
||||
def _create_issue(hass: HomeAssistant) -> None:
|
||||
"""Create the integration removed repair issue."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="integration_removed",
|
||||
translation_placeholders={
|
||||
"entries": "/config/integrations/integration/konnected",
|
||||
"kb_page_url": "https://support.konnected.io/migrating-from-konnected-legacy-home-assistant-integration-to-esphome",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Support for wired binary sensors attached to a Konnected device."""
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_STATE,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_NAME,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up binary sensors attached to a Konnected device from a config entry."""
|
||||
# Uses legacy hass.data[DOMAIN] pattern
|
||||
# pylint: disable-next=home-assistant-use-runtime-data
|
||||
data = hass.data[DOMAIN]
|
||||
device_id = config_entry.data["id"]
|
||||
sensors = [
|
||||
KonnectedBinarySensor(device_id, pin_num, pin_data)
|
||||
for pin_num, pin_data in data[CONF_DEVICES][device_id][
|
||||
CONF_BINARY_SENSORS
|
||||
].items()
|
||||
]
|
||||
async_add_entities(sensors)
|
||||
|
||||
|
||||
class KonnectedBinarySensor(BinarySensorEntity):
|
||||
"""Representation of a Konnected binary sensor."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, device_id, zone_num, data):
|
||||
"""Initialize the Konnected binary sensor."""
|
||||
self._data = data
|
||||
self._attr_is_on = data.get(ATTR_STATE)
|
||||
self._attr_device_class = data.get(CONF_TYPE)
|
||||
self._attr_unique_id = f"{device_id}-{zone_num}"
|
||||
self._attr_name = data.get(CONF_NAME)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Store entity_id and register state change callback."""
|
||||
self._data[ATTR_ENTITY_ID] = self.entity_id
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, f"konnected.{self.entity_id}.update", self.async_set_state
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_state(self, state):
|
||||
"""Update the sensor's state."""
|
||||
self._attr_is_on = state
|
||||
self.async_write_ha_state()
|
||||
@@ -1,892 +1,11 @@
|
||||
"""Config flow for konnected.io integration."""
|
||||
# pylint: disable=home-assistant-config-flow-name-field # Name field is no longer allowed in config flow schemas
|
||||
"""Config flow for Konnected.io integration."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASSES_SCHEMA,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DISCOVERY,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_MODEL,
|
||||
CONF_NAME,
|
||||
CONF_PORT,
|
||||
CONF_REPEAT,
|
||||
CONF_SENSORS,
|
||||
CONF_SWITCHES,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.service_info.ssdp import (
|
||||
ATTR_UPNP_MANUFACTURER,
|
||||
ATTR_UPNP_MODEL_NAME,
|
||||
SsdpServiceInfo,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_API_HOST,
|
||||
CONF_BLINK,
|
||||
CONF_DEFAULT_OPTIONS,
|
||||
CONF_INVERSE,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
CONF_POLL_INTERVAL,
|
||||
DOMAIN,
|
||||
STATE_HIGH,
|
||||
STATE_LOW,
|
||||
ZONES,
|
||||
)
|
||||
from .errors import CannotConnect
|
||||
from .panel import KONN_MODEL, KONN_MODEL_PRO, get_status
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_KONN_UPNP_MODEL_NAME = "model_name" # standard upnp is modelName
|
||||
CONF_IO = "io"
|
||||
CONF_IO_DIS = "Disabled"
|
||||
CONF_IO_BIN = "Binary Sensor"
|
||||
CONF_IO_DIG = "Digital Sensor"
|
||||
CONF_IO_SWI = "Switchable Output"
|
||||
|
||||
CONF_MORE_STATES = "more_states"
|
||||
CONF_YES = "Yes"
|
||||
CONF_NO = "No"
|
||||
|
||||
CONF_OVERRIDE_API_HOST = "override_api_host"
|
||||
|
||||
KONN_MANUFACTURER = "konnected.io"
|
||||
KONN_PANEL_MODEL_NAMES = {
|
||||
KONN_MODEL: "Konnected Alarm Panel",
|
||||
KONN_MODEL_PRO: "Konnected Alarm Panel Pro",
|
||||
}
|
||||
|
||||
OPTIONS_IO_ANY = vol.In([CONF_IO_DIS, CONF_IO_BIN, CONF_IO_DIG, CONF_IO_SWI])
|
||||
OPTIONS_IO_INPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_BIN])
|
||||
OPTIONS_IO_OUTPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_SWI])
|
||||
|
||||
|
||||
# Config entry schemas
|
||||
IO_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("1", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("2", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("3", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("4", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("5", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("6", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("7", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("8", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("9", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("10", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("11", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("12", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("out", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Optional("alarm1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Optional("out1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Optional("alarm2_out2", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
}
|
||||
)
|
||||
|
||||
BINARY_SENSOR_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE): vol.In(ZONES),
|
||||
vol.Required(
|
||||
CONF_TYPE, default=BinarySensorDeviceClass.DOOR
|
||||
): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_INVERSE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
SENSOR_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE): vol.In(ZONES),
|
||||
vol.Required(CONF_TYPE, default="dht"): vol.All(
|
||||
vol.Lower, vol.In(["dht", "ds18b20"])
|
||||
),
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SWITCH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE): vol.In(ZONES),
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All(
|
||||
vol.Lower, vol.In([STATE_HIGH, STATE_LOW])
|
||||
),
|
||||
vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_REPEAT): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
}
|
||||
)
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IO): IO_SCHEMA,
|
||||
vol.Optional(CONF_BINARY_SENSORS): vol.All(
|
||||
cv.ensure_list, [BINARY_SENSOR_SCHEMA]
|
||||
),
|
||||
vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA]),
|
||||
vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA]),
|
||||
vol.Optional(CONF_BLINK, default=True): cv.boolean,
|
||||
vol.Optional(CONF_API_HOST, default=""): vol.Any("", cv.url),
|
||||
vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
CONFIG_ENTRY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"),
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PORT): cv.port,
|
||||
vol.Required(CONF_MODEL): vol.Any(*KONN_PANEL_MODEL_NAMES),
|
||||
vol.Required(CONF_ACCESS_TOKEN): cv.matches_regex("[a-zA-Z0-9]+"),
|
||||
vol.Required(CONF_DEFAULT_OPTIONS): OPTIONS_SCHEMA,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Konnected Panels."""
|
||||
"""Handle a config flow for Konnected.io."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
# class variable to store/share discovered host information
|
||||
DISCOVERED_HOSTS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
unique_id: str
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the Konnected flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.options = OPTIONS_SCHEMA({CONF_IO: {}})
|
||||
|
||||
async def async_gen_config(self, host, port):
|
||||
"""Populate self.data based on panel status.
|
||||
|
||||
This will raise CannotConnect if an error occurs
|
||||
"""
|
||||
self.data[CONF_HOST] = host
|
||||
self.data[CONF_PORT] = port
|
||||
try:
|
||||
status = await get_status(self.hass, host, port)
|
||||
self.data[CONF_ID] = status.get("chipId", status["mac"].replace(":", ""))
|
||||
except (CannotConnect, KeyError) as err:
|
||||
raise CannotConnect from err
|
||||
|
||||
self.data[CONF_MODEL] = status.get("model", KONN_MODEL)
|
||||
self.data[CONF_ACCESS_TOKEN] = "".join(
|
||||
random.choices(f"{string.ascii_uppercase}{string.digits}", k=20)
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import a configuration.yaml config.
|
||||
|
||||
This flow is triggered by `async_setup` for configured panels.
|
||||
"""
|
||||
_LOGGER.debug(import_data)
|
||||
|
||||
# save the data and confirm connection via user step
|
||||
await self.async_set_unique_id(import_data["id"])
|
||||
self.options = import_data[CONF_DEFAULT_OPTIONS]
|
||||
|
||||
# config schema ensures we have port if we have host
|
||||
if import_data.get(CONF_HOST):
|
||||
# automatically connect if we have host info
|
||||
return await self.async_step_user(
|
||||
user_input={
|
||||
CONF_HOST: import_data[CONF_HOST],
|
||||
CONF_PORT: import_data[CONF_PORT],
|
||||
}
|
||||
)
|
||||
|
||||
# if we have no host info wait for it or abort if previously configured
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_import_confirm()
|
||||
|
||||
async def async_step_import_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the user wants to import the config entry."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="import_confirm",
|
||||
description_placeholders={"id": self.unique_id},
|
||||
)
|
||||
|
||||
# if we have ssdp discovered applicable host info use it
|
||||
if KonnectedFlowHandler.DISCOVERED_HOSTS.get(self.unique_id):
|
||||
return await self.async_step_user(
|
||||
user_input={
|
||||
CONF_HOST: KonnectedFlowHandler.DISCOVERED_HOSTS[self.unique_id][
|
||||
CONF_HOST
|
||||
],
|
||||
CONF_PORT: KonnectedFlowHandler.DISCOVERED_HOSTS[self.unique_id][
|
||||
CONF_PORT
|
||||
],
|
||||
}
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_ssdp(
|
||||
self, discovery_info: SsdpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a discovered konnected panel.
|
||||
|
||||
This flow is triggered by the SSDP component. It will check if the
|
||||
device is already configured and attempt to finish the config if not.
|
||||
"""
|
||||
_LOGGER.debug(discovery_info)
|
||||
|
||||
try:
|
||||
if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER:
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
if not any(
|
||||
name in discovery_info.upnp[ATTR_UPNP_MODEL_NAME]
|
||||
for name in KONN_PANEL_MODEL_NAMES
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Discovered unrecognized Konnected device %s",
|
||||
discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "Unknown"),
|
||||
)
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
# If MAC is missing it is a bug in the device fw but we'll guard
|
||||
# against it since the field is so vital
|
||||
except KeyError:
|
||||
_LOGGER.error("Malformed Konnected SSDP info")
|
||||
else:
|
||||
# extract host/port from ssdp_location
|
||||
assert discovery_info.ssdp_location
|
||||
netloc = urlparse(discovery_info.ssdp_location).netloc.split(":")
|
||||
self._async_abort_entries_match(
|
||||
{CONF_HOST: netloc[0], CONF_PORT: int(netloc[1])}
|
||||
)
|
||||
|
||||
try:
|
||||
status = await get_status(self.hass, netloc[0], int(netloc[1]))
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self.data[CONF_HOST] = netloc[0]
|
||||
self.data[CONF_PORT] = int(netloc[1])
|
||||
self.data[CONF_ID] = status.get("chipId", status["mac"].replace(":", ""))
|
||||
self.data[CONF_MODEL] = status.get("model", KONN_MODEL)
|
||||
|
||||
KonnectedFlowHandler.DISCOVERED_HOSTS[self.data[CONF_ID]] = {
|
||||
CONF_HOST: self.data[CONF_HOST],
|
||||
CONF_PORT: self.data[CONF_PORT],
|
||||
}
|
||||
return await self.async_step_confirm()
|
||||
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Connect to panel and get config."""
|
||||
errors = {}
|
||||
if user_input:
|
||||
# build config info and wait for user confirmation
|
||||
self.data[CONF_HOST] = user_input[CONF_HOST]
|
||||
self.data[CONF_PORT] = user_input[CONF_PORT]
|
||||
|
||||
# brief delay to allow processing of recent status req
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
status = await get_status(
|
||||
self.hass, self.data[CONF_HOST], self.data[CONF_PORT]
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
self.data[CONF_ID] = status.get(
|
||||
"chipId", status["mac"].replace(":", "")
|
||||
)
|
||||
self.data[CONF_MODEL] = status.get("model", KONN_MODEL)
|
||||
|
||||
# save off our discovered host info
|
||||
KonnectedFlowHandler.DISCOVERED_HOSTS[self.data[CONF_ID]] = {
|
||||
CONF_HOST: self.data[CONF_HOST],
|
||||
CONF_PORT: self.data[CONF_PORT],
|
||||
}
|
||||
return await self.async_step_confirm()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
description_placeholders={
|
||||
"host": self.data.get(CONF_HOST, "Unknown"),
|
||||
"port": self.data.get(CONF_PORT, "Unknown"),
|
||||
},
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=self.data.get(CONF_HOST)): str,
|
||||
vol.Required(CONF_PORT, default=self.data.get(CONF_PORT)): int,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Attempt to link with the Konnected panel.
|
||||
|
||||
Given a configured host, will ask the user to confirm and finalize
|
||||
the connection.
|
||||
"""
|
||||
if user_input is None:
|
||||
# abort and update an existing config entry if host info changes
|
||||
await self.async_set_unique_id(self.data[CONF_ID])
|
||||
self._abort_if_unique_id_configured(
|
||||
updates=self.data, reload_on_update=False
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]],
|
||||
"id": self.unique_id,
|
||||
"host": self.data[CONF_HOST],
|
||||
"port": self.data[CONF_PORT],
|
||||
},
|
||||
)
|
||||
|
||||
# Create access token, attach default options and create entry
|
||||
self.data[CONF_DEFAULT_OPTIONS] = self.options
|
||||
self.data[CONF_ACCESS_TOKEN] = self.hass.data.get(DOMAIN, {}).get(
|
||||
CONF_ACCESS_TOKEN
|
||||
) or "".join(random.choices(f"{string.ascii_uppercase}{string.digits}", k=20))
|
||||
|
||||
return self.async_create_entry(
|
||||
title=KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]],
|
||||
data=self.data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlowHandler:
|
||||
"""Return the Options Flow."""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlow):
|
||||
"""Handle a option flow for a Konnected Panel."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.model = config_entry.data[CONF_MODEL]
|
||||
self.current_opt = (
|
||||
config_entry.options or config_entry.data[CONF_DEFAULT_OPTIONS]
|
||||
)
|
||||
|
||||
# as config proceeds we'll build up new options
|
||||
# and then replace what's in the config entry
|
||||
self.new_opt: dict[str, Any] = {CONF_IO: {}}
|
||||
self.active_cfg: str | None = None
|
||||
self.io_cfg: dict[str, Any] = {}
|
||||
self.current_states: list[dict[str, Any]] = []
|
||||
self.current_state = 1
|
||||
|
||||
@callback
|
||||
def get_current_cfg(self, io_type, zone):
|
||||
"""Get the current zone config."""
|
||||
return next(
|
||||
(
|
||||
cfg
|
||||
for cfg in self.current_opt.get(io_type, [])
|
||||
if cfg[CONF_ZONE] == zone
|
||||
),
|
||||
{},
|
||||
)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle options flow."""
|
||||
return await self.async_step_options_io()
|
||||
|
||||
async def async_step_options_io(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Configure legacy panel IO or first half of pro IO."""
|
||||
errors: dict[str, str] = {}
|
||||
current_io = self.current_opt.get(CONF_IO, {})
|
||||
|
||||
if user_input is not None:
|
||||
# strip out disabled io and save for options cfg
|
||||
for key, value in user_input.items():
|
||||
if value != CONF_IO_DIS:
|
||||
self.new_opt[CONF_IO][key] = value
|
||||
return await self.async_step_options_io_ext()
|
||||
|
||||
if self.model == KONN_MODEL:
|
||||
return self.async_show_form(
|
||||
step_id="options_io",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"1", default=current_io.get("1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"2", default=current_io.get("2", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"3", default=current_io.get("3", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"4", default=current_io.get("4", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"5", default=current_io.get("5", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"6", default=current_io.get("6", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"out", default=current_io.get("out", CONF_IO_DIS)
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.model],
|
||||
"host": self.config_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# configure the first half of the pro board io
|
||||
if self.model == KONN_MODEL_PRO:
|
||||
return self.async_show_form(
|
||||
step_id="options_io",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"1", default=current_io.get("1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"2", default=current_io.get("2", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"3", default=current_io.get("3", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"4", default=current_io.get("4", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"5", default=current_io.get("5", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"6", default=current_io.get("6", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"7", default=current_io.get("7", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.model],
|
||||
"host": self.config_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
async def async_step_options_io_ext(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the extended IO for pro."""
|
||||
errors: dict[str, str] = {}
|
||||
current_io = self.current_opt.get(CONF_IO, {})
|
||||
|
||||
if user_input is not None:
|
||||
# strip out disabled io and save for options cfg
|
||||
for key, value in user_input.items():
|
||||
if value != CONF_IO_DIS:
|
||||
self.new_opt[CONF_IO].update({key: value})
|
||||
self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO])
|
||||
return await self.async_step_options_binary()
|
||||
|
||||
if self.model == KONN_MODEL:
|
||||
self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO])
|
||||
return await self.async_step_options_binary()
|
||||
|
||||
if self.model == KONN_MODEL_PRO:
|
||||
return self.async_show_form(
|
||||
step_id="options_io_ext",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"8", default=current_io.get("8", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"9", default=current_io.get("9", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"10", default=current_io.get("10", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"11", default=current_io.get("11", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"12", default=current_io.get("12", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"alarm1", default=current_io.get("alarm1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Required(
|
||||
"out1", default=current_io.get("out1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Required(
|
||||
"alarm2_out2",
|
||||
default=current_io.get("alarm2_out2", CONF_IO_DIS),
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.model],
|
||||
"host": self.config_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
async def async_step_options_binary(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the IO options for binary sensors."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None and self.active_cfg is not None:
|
||||
zone = {"zone": self.active_cfg}
|
||||
zone.update(user_input)
|
||||
self.new_opt[CONF_BINARY_SENSORS] = [
|
||||
*self.new_opt.get(CONF_BINARY_SENSORS, []),
|
||||
zone,
|
||||
]
|
||||
self.io_cfg.pop(self.active_cfg)
|
||||
self.active_cfg = None
|
||||
|
||||
if self.active_cfg:
|
||||
current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_binary",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE,
|
||||
default=current_cfg.get(
|
||||
CONF_TYPE, BinarySensorDeviceClass.DOOR
|
||||
),
|
||||
): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(
|
||||
CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_INVERSE, default=current_cfg.get(CONF_INVERSE, False)
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# find the next unconfigured binary sensor
|
||||
for key, value in self.io_cfg.items():
|
||||
if value == CONF_IO_BIN:
|
||||
self.active_cfg = key
|
||||
current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_binary",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE,
|
||||
default=current_cfg.get(
|
||||
CONF_TYPE, BinarySensorDeviceClass.DOOR
|
||||
),
|
||||
): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(
|
||||
CONF_NAME,
|
||||
default=current_cfg.get(CONF_NAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_INVERSE,
|
||||
default=current_cfg.get(CONF_INVERSE, False),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return await self.async_step_options_digital()
|
||||
|
||||
async def async_step_options_digital(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the IO options for digital sensors."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None and self.active_cfg is not None:
|
||||
zone = {"zone": self.active_cfg}
|
||||
zone.update(user_input)
|
||||
self.new_opt[CONF_SENSORS] = [*self.new_opt.get(CONF_SENSORS, []), zone]
|
||||
self.io_cfg.pop(self.active_cfg)
|
||||
self.active_cfg = None
|
||||
|
||||
if self.active_cfg:
|
||||
current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_digital",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht")
|
||||
): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])),
|
||||
vol.Optional(
|
||||
CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_POLL_INTERVAL,
|
||||
default=current_cfg.get(CONF_POLL_INTERVAL, 3),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# find the next unconfigured digital sensor
|
||||
for key, value in self.io_cfg.items():
|
||||
if value == CONF_IO_DIG:
|
||||
self.active_cfg = key
|
||||
current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_digital",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht")
|
||||
): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])),
|
||||
vol.Optional(
|
||||
CONF_NAME,
|
||||
default=current_cfg.get(CONF_NAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_POLL_INTERVAL,
|
||||
default=current_cfg.get(CONF_POLL_INTERVAL, 3),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return await self.async_step_options_switch()
|
||||
|
||||
async def async_step_options_switch(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the IO options for switches."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None and self.active_cfg is not None:
|
||||
zone = {"zone": self.active_cfg}
|
||||
zone.update(user_input)
|
||||
del zone[CONF_MORE_STATES]
|
||||
self.new_opt[CONF_SWITCHES] = [*self.new_opt.get(CONF_SWITCHES, []), zone]
|
||||
|
||||
# iterate through multiple switch states
|
||||
if self.current_states:
|
||||
self.current_states.pop(0)
|
||||
|
||||
# only go to next zone if all states are entered
|
||||
self.current_state += 1
|
||||
if user_input[CONF_MORE_STATES] == CONF_NO:
|
||||
self.io_cfg.pop(self.active_cfg)
|
||||
self.active_cfg = None
|
||||
|
||||
if self.active_cfg:
|
||||
current_cfg = next(iter(self.current_states), {})
|
||||
return self.async_show_form(
|
||||
step_id="options_switch",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_ACTIVATION,
|
||||
default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH),
|
||||
): vol.All(vol.Lower, vol.In([STATE_HIGH, STATE_LOW])),
|
||||
vol.Optional(
|
||||
CONF_MOMENTARY,
|
||||
default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_PAUSE,
|
||||
default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_REPEAT,
|
||||
default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
vol.Required(
|
||||
CONF_MORE_STATES,
|
||||
default=CONF_YES
|
||||
if len(self.current_states) > 1
|
||||
else CONF_NO,
|
||||
): vol.In([CONF_YES, CONF_NO]),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper(),
|
||||
"state": str(self.current_state),
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# find the next unconfigured switch
|
||||
for key, value in self.io_cfg.items():
|
||||
if value == CONF_IO_SWI:
|
||||
self.active_cfg = key
|
||||
self.current_states = [
|
||||
cfg
|
||||
for cfg in self.current_opt.get(CONF_SWITCHES, [])
|
||||
if cfg[CONF_ZONE] == self.active_cfg
|
||||
]
|
||||
current_cfg = next(iter(self.current_states), {})
|
||||
self.current_state = 1
|
||||
return self.async_show_form(
|
||||
step_id="options_switch",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_NAME,
|
||||
default=current_cfg.get(CONF_NAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_ACTIVATION,
|
||||
default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH),
|
||||
): vol.In(["low", "high"]),
|
||||
vol.Optional(
|
||||
CONF_MOMENTARY,
|
||||
default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_PAUSE,
|
||||
default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_REPEAT,
|
||||
default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
vol.Required(
|
||||
CONF_MORE_STATES,
|
||||
default=CONF_YES
|
||||
if len(self.current_states) > 1
|
||||
else CONF_NO,
|
||||
): vol.In([CONF_YES, CONF_NO]),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper(),
|
||||
"state": str(self.current_state),
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return await self.async_step_options_misc()
|
||||
|
||||
async def async_step_options_misc(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the LED behavior."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
# config schema only does basic schema val so check url here
|
||||
try:
|
||||
if user_input[CONF_OVERRIDE_API_HOST]:
|
||||
cv.url(user_input.get(CONF_API_HOST, ""))
|
||||
else:
|
||||
user_input[CONF_API_HOST] = ""
|
||||
except vol.Invalid:
|
||||
errors["base"] = "bad_host"
|
||||
else:
|
||||
# no need to store the override - can infer
|
||||
del user_input[CONF_OVERRIDE_API_HOST]
|
||||
self.new_opt.update(user_input)
|
||||
return self.async_create_entry(title="", data=self.new_opt)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="options_misc",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_DISCOVERY,
|
||||
default=self.current_opt.get(CONF_DISCOVERY, True),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_BLINK, default=self.current_opt.get(CONF_BLINK, True)
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_OVERRIDE_API_HOST,
|
||||
default=bool(self.current_opt.get(CONF_API_HOST)),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_API_HOST, default=self.current_opt.get(CONF_API_HOST, "")
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -1,46 +1,3 @@
|
||||
"""Konnected constants."""
|
||||
|
||||
DOMAIN = "konnected"
|
||||
|
||||
CONF_ACTIVATION = "activation"
|
||||
CONF_API_HOST = "api_host"
|
||||
CONF_DEFAULT_OPTIONS = "default_options"
|
||||
CONF_MOMENTARY = "momentary"
|
||||
CONF_PAUSE = "pause"
|
||||
CONF_POLL_INTERVAL = "poll_interval"
|
||||
CONF_PRECISION = "precision"
|
||||
CONF_INVERSE = "inverse"
|
||||
CONF_BLINK = "blink"
|
||||
CONF_DHT_SENSORS = "dht_sensors"
|
||||
CONF_DS18B20_SENSORS = "ds18b20_sensors"
|
||||
|
||||
STATE_LOW = "low"
|
||||
STATE_HIGH = "high"
|
||||
|
||||
ZONES = [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"11",
|
||||
"12",
|
||||
"alarm1",
|
||||
"out1",
|
||||
"alarm2_out2",
|
||||
"out",
|
||||
]
|
||||
|
||||
# alarm panel pro only handles zones,
|
||||
# alarm panel allows specifying pins via configuration.yaml
|
||||
PIN_TO_ZONE = {"1": "1", "2": "2", "5": "3", "6": "4", "7": "5", "8": "out", "9": "6"}
|
||||
ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()}
|
||||
|
||||
ENDPOINT_ROOT = "/api/konnected"
|
||||
UPDATE_ENDPOINT = ENDPOINT_ROOT + r"/device/{device_id:[a-zA-Z0-9]+}"
|
||||
SIGNAL_DS18B20_NEW = "konnected.ds18b20.new"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Errors for the Konnected component."""
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class KonnectedException(HomeAssistantError):
|
||||
"""Base class for Konnected exceptions."""
|
||||
|
||||
|
||||
class CannotConnect(KonnectedException):
|
||||
"""Unable to connect to the panel."""
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Handle Konnected messages."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.util import decorator
|
||||
|
||||
from .const import CONF_INVERSE, SIGNAL_DS18B20_NEW
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
HANDLERS = decorator.Registry() # type: ignore[var-annotated]
|
||||
|
||||
|
||||
@HANDLERS.register("state")
|
||||
async def async_handle_state_update(hass, context, msg):
|
||||
"""Handle a binary sensor or switch state update."""
|
||||
_LOGGER.debug("[state handler] context: %s msg: %s", context, msg)
|
||||
entity_id = context.get(ATTR_ENTITY_ID)
|
||||
state = bool(int(msg.get(ATTR_STATE)))
|
||||
if context.get(CONF_INVERSE):
|
||||
state = not state
|
||||
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", state)
|
||||
|
||||
|
||||
@HANDLERS.register("temp")
|
||||
async def async_handle_temp_update(hass, context, msg):
|
||||
"""Handle a temperature sensor state update."""
|
||||
_LOGGER.debug("[temp handler] context: %s msg: %s", context, msg)
|
||||
entity_id, temp = context.get(SensorDeviceClass.TEMPERATURE), msg.get("temp")
|
||||
if entity_id:
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp)
|
||||
|
||||
|
||||
@HANDLERS.register("humi")
|
||||
async def async_handle_humi_update(hass, context, msg):
|
||||
"""Handle a humidity sensor state update."""
|
||||
_LOGGER.debug("[humi handler] context: %s msg: %s", context, msg)
|
||||
entity_id, humi = context.get(SensorDeviceClass.HUMIDITY), msg.get("humi")
|
||||
if entity_id:
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", humi)
|
||||
|
||||
|
||||
@HANDLERS.register("addr")
|
||||
async def async_handle_addr_update(hass, context, msg):
|
||||
"""Handle an addressable sensor update."""
|
||||
_LOGGER.debug("[addr handler] context: %s msg: %s", context, msg)
|
||||
addr, temp = msg.get("addr"), msg.get("temp")
|
||||
if entity_id := context.get(addr):
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp)
|
||||
else:
|
||||
msg["device_id"] = context.get("device_id")
|
||||
msg["temperature"] = temp
|
||||
msg["addr"] = addr
|
||||
async_dispatcher_send(hass, SIGNAL_DS18B20_NEW, msg)
|
||||
@@ -1,17 +1,9 @@
|
||||
{
|
||||
"domain": "konnected",
|
||||
"name": "Konnected.io (Legacy)",
|
||||
"codeowners": ["@heythisisnate"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/konnected",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["konnected"],
|
||||
"requirements": ["konnected==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "konnected.io"
|
||||
}
|
||||
]
|
||||
"requirements": []
|
||||
}
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
"""Support for Konnected devices."""
|
||||
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import konnected
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_STATE,
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_DISCOVERY,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_PIN,
|
||||
CONF_PORT,
|
||||
CONF_REPEAT,
|
||||
CONF_SENSORS,
|
||||
CONF_SWITCHES,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import aiohttp_client, device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.network import get_url
|
||||
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_API_HOST,
|
||||
CONF_BLINK,
|
||||
CONF_DEFAULT_OPTIONS,
|
||||
CONF_DHT_SENSORS,
|
||||
CONF_DS18B20_SENSORS,
|
||||
CONF_INVERSE,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
CONF_POLL_INTERVAL,
|
||||
DOMAIN,
|
||||
ENDPOINT_ROOT,
|
||||
STATE_LOW,
|
||||
ZONE_TO_PIN,
|
||||
)
|
||||
from .errors import CannotConnect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
KONN_MODEL = "Konnected"
|
||||
KONN_MODEL_PRO = "Konnected Pro"
|
||||
|
||||
# Indicate how each unit is controlled (pin or zone)
|
||||
KONN_API_VERSIONS = {
|
||||
KONN_MODEL: CONF_PIN,
|
||||
KONN_MODEL_PRO: CONF_ZONE,
|
||||
}
|
||||
|
||||
|
||||
class AlarmPanel:
|
||||
"""A representation of a Konnected alarm panel."""
|
||||
|
||||
def __init__(self, hass, config_entry):
|
||||
"""Initialize the Konnected device."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.config = config_entry.data
|
||||
self.options = config_entry.options or config_entry.data.get(
|
||||
CONF_DEFAULT_OPTIONS, {}
|
||||
)
|
||||
self.host = self.config.get(CONF_HOST)
|
||||
self.port = self.config.get(CONF_PORT)
|
||||
self.client = None
|
||||
self.status = None
|
||||
self.api_version = KONN_API_VERSIONS[KONN_MODEL]
|
||||
self.connected = False
|
||||
self.connect_attempts = 0
|
||||
self.cancel_connect_retry = None
|
||||
|
||||
@property
|
||||
def device_id(self):
|
||||
"""Device id is the chipId (pro) or MAC address as string."""
|
||||
return self.config.get(CONF_ID)
|
||||
|
||||
@property
|
||||
def stored_configuration(self):
|
||||
"""Return the configuration stored in `hass.data` for this device."""
|
||||
return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id)
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return whether the device is available."""
|
||||
return self.connected
|
||||
|
||||
def format_zone(self, zone, other_items=None):
|
||||
"""Get zone or pin based dict based on the client type."""
|
||||
payload = {
|
||||
self.api_version: zone
|
||||
if self.api_version == CONF_ZONE
|
||||
else ZONE_TO_PIN[zone]
|
||||
}
|
||||
payload.update(other_items or {})
|
||||
return payload
|
||||
|
||||
async def async_connect(self, now=None):
|
||||
"""Connect to and setup a Konnected device."""
|
||||
if self.connected:
|
||||
return
|
||||
|
||||
if self.cancel_connect_retry:
|
||||
# cancel any pending connect attempt and try now
|
||||
self.cancel_connect_retry()
|
||||
|
||||
try:
|
||||
self.client = konnected.Client(
|
||||
host=self.host,
|
||||
port=str(self.port),
|
||||
websession=aiohttp_client.async_get_clientsession(self.hass),
|
||||
)
|
||||
self.status = await self.client.get_status()
|
||||
self.api_version = KONN_API_VERSIONS.get(
|
||||
self.status.get("model", KONN_MODEL), KONN_API_VERSIONS[KONN_MODEL]
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Connected to new %s device", self.status.get("model", "Konnected")
|
||||
)
|
||||
_LOGGER.debug(self.status)
|
||||
|
||||
await self.async_update_initial_states()
|
||||
# brief delay to allow processing of recent status req
|
||||
await asyncio.sleep(0.1)
|
||||
await self.async_sync_device_config()
|
||||
|
||||
except self.client.ClientError as err:
|
||||
_LOGGER.warning("Exception trying to connect to panel: %s", err)
|
||||
|
||||
# retry in a bit, never more than ~3 min
|
||||
self.connect_attempts += 1
|
||||
self.cancel_connect_retry = async_call_later(
|
||||
self.hass, 2 ** min(self.connect_attempts, 5) * 5, self.async_connect
|
||||
)
|
||||
return
|
||||
|
||||
self.connect_attempts = 0
|
||||
self.connected = True
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"Set up Konnected device %s. Open http://%s:%s in a "
|
||||
"web browser to view device status"
|
||||
),
|
||||
self.device_id,
|
||||
self.host,
|
||||
self.port,
|
||||
)
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self.status.get("mac"))},
|
||||
identifiers={(DOMAIN, self.device_id)},
|
||||
manufacturer="Konnected.io",
|
||||
name=self.config_entry.title,
|
||||
model=self.config_entry.title,
|
||||
sw_version=self.status.get("swVersion"),
|
||||
)
|
||||
|
||||
async def update_switch(self, zone, state, momentary=None, times=None, pause=None):
|
||||
"""Update the state of a switchable output."""
|
||||
try:
|
||||
if self.client:
|
||||
if self.api_version == CONF_ZONE:
|
||||
return await self.client.put_zone(
|
||||
zone,
|
||||
state,
|
||||
momentary,
|
||||
times,
|
||||
pause,
|
||||
)
|
||||
|
||||
# device endpoint uses pin number instead of zone
|
||||
return await self.client.put_device(
|
||||
ZONE_TO_PIN[zone],
|
||||
state,
|
||||
momentary,
|
||||
times,
|
||||
pause,
|
||||
)
|
||||
|
||||
except self.client.ClientError as err:
|
||||
_LOGGER.warning("Exception trying to update panel: %s", err)
|
||||
|
||||
raise CannotConnect
|
||||
|
||||
async def async_save_data(self):
|
||||
"""Save the device configuration to `hass.data`."""
|
||||
binary_sensors = {}
|
||||
for entity in self.options.get(CONF_BINARY_SENSORS) or []:
|
||||
zone = entity[CONF_ZONE]
|
||||
|
||||
binary_sensors[zone] = {
|
||||
CONF_TYPE: entity[CONF_TYPE],
|
||||
CONF_NAME: entity.get(
|
||||
CONF_NAME, f"Konnected {self.device_id[6:]} Zone {zone}"
|
||||
),
|
||||
CONF_INVERSE: entity.get(CONF_INVERSE),
|
||||
ATTR_STATE: None,
|
||||
}
|
||||
_LOGGER.debug(
|
||||
"Set up binary_sensor %s (initial state: %s)",
|
||||
binary_sensors[zone].get("name"),
|
||||
binary_sensors[zone].get(ATTR_STATE),
|
||||
)
|
||||
|
||||
actuators = []
|
||||
for entity in self.options.get(CONF_SWITCHES) or []:
|
||||
zone = entity[CONF_ZONE]
|
||||
|
||||
act = {
|
||||
CONF_ZONE: zone,
|
||||
CONF_NAME: entity.get(
|
||||
CONF_NAME,
|
||||
f"Konnected {self.device_id[6:]} Actuator {zone}",
|
||||
),
|
||||
ATTR_STATE: None,
|
||||
CONF_ACTIVATION: entity[CONF_ACTIVATION],
|
||||
CONF_MOMENTARY: entity.get(CONF_MOMENTARY),
|
||||
CONF_PAUSE: entity.get(CONF_PAUSE),
|
||||
CONF_REPEAT: entity.get(CONF_REPEAT),
|
||||
}
|
||||
actuators.append(act)
|
||||
_LOGGER.debug("Set up switch %s", act)
|
||||
|
||||
sensors = []
|
||||
for entity in self.options.get(CONF_SENSORS) or []:
|
||||
zone = entity[CONF_ZONE]
|
||||
|
||||
sensor = {
|
||||
CONF_ZONE: zone,
|
||||
CONF_NAME: entity.get(
|
||||
CONF_NAME, f"Konnected {self.device_id[6:]} Sensor {zone}"
|
||||
),
|
||||
CONF_TYPE: entity[CONF_TYPE],
|
||||
CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL),
|
||||
}
|
||||
sensors.append(sensor)
|
||||
_LOGGER.debug(
|
||||
"Set up %s sensor %s (initial state: %s)",
|
||||
sensor.get(CONF_TYPE),
|
||||
sensor.get(CONF_NAME),
|
||||
sensor.get(ATTR_STATE),
|
||||
)
|
||||
|
||||
device_data = {
|
||||
CONF_BINARY_SENSORS: binary_sensors,
|
||||
CONF_SENSORS: sensors,
|
||||
CONF_SWITCHES: actuators,
|
||||
CONF_BLINK: self.options.get(CONF_BLINK),
|
||||
CONF_DISCOVERY: self.options.get(CONF_DISCOVERY),
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
"panel": self,
|
||||
}
|
||||
|
||||
if CONF_DEVICES not in self.hass.data[DOMAIN]:
|
||||
self.hass.data[DOMAIN][CONF_DEVICES] = {}
|
||||
|
||||
_LOGGER.debug(
|
||||
"Storing data in hass.data[%s][%s][%s]: %s",
|
||||
DOMAIN,
|
||||
CONF_DEVICES,
|
||||
self.device_id,
|
||||
device_data,
|
||||
)
|
||||
self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data
|
||||
|
||||
@callback
|
||||
def async_binary_sensor_configuration(self):
|
||||
"""Return the configuration map for syncing binary sensors."""
|
||||
return [
|
||||
self.format_zone(p) for p in self.stored_configuration[CONF_BINARY_SENSORS]
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_actuator_configuration(self):
|
||||
"""Return the configuration map for syncing actuators."""
|
||||
return [
|
||||
self.format_zone(
|
||||
data[CONF_ZONE],
|
||||
{"trigger": (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] else 1)},
|
||||
)
|
||||
for data in self.stored_configuration[CONF_SWITCHES]
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_dht_sensor_configuration(self):
|
||||
"""Return the configuration map for syncing DHT sensors."""
|
||||
return [
|
||||
self.format_zone(
|
||||
sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]}
|
||||
)
|
||||
for sensor in self.stored_configuration[CONF_SENSORS]
|
||||
if sensor[CONF_TYPE] == "dht"
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_ds18b20_sensor_configuration(self):
|
||||
"""Return the configuration map for syncing DS18B20 sensors."""
|
||||
return [
|
||||
self.format_zone(
|
||||
sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]}
|
||||
)
|
||||
for sensor in self.stored_configuration[CONF_SENSORS]
|
||||
if sensor[CONF_TYPE] == "ds18b20"
|
||||
]
|
||||
|
||||
async def async_update_initial_states(self):
|
||||
"""Update the initial state of each sensor from status poll."""
|
||||
for sensor_data in self.status.get("sensors"):
|
||||
sensor_config = self.stored_configuration[CONF_BINARY_SENSORS].get(
|
||||
sensor_data.get(CONF_ZONE, sensor_data.get(CONF_PIN)), {}
|
||||
)
|
||||
entity_id = sensor_config.get(ATTR_ENTITY_ID)
|
||||
|
||||
state = bool(sensor_data.get(ATTR_STATE))
|
||||
if sensor_config.get(CONF_INVERSE):
|
||||
state = not state
|
||||
|
||||
async_dispatcher_send(self.hass, f"konnected.{entity_id}.update", state)
|
||||
|
||||
@callback
|
||||
def async_desired_settings_payload(self):
|
||||
"""Return a dict representing the desired device configuration."""
|
||||
# keeping self.hass.data check for backwards compatibility
|
||||
# newly configured integrations store this in the config entry
|
||||
desired_api_host = self.options.get(CONF_API_HOST) or (
|
||||
self.hass.data[DOMAIN].get(CONF_API_HOST) or get_url(self.hass)
|
||||
)
|
||||
desired_api_endpoint = desired_api_host + ENDPOINT_ROOT
|
||||
|
||||
return {
|
||||
"sensors": self.async_binary_sensor_configuration(),
|
||||
"actuators": self.async_actuator_configuration(),
|
||||
"dht_sensors": self.async_dht_sensor_configuration(),
|
||||
"ds18b20_sensors": self.async_ds18b20_sensor_configuration(),
|
||||
"auth_token": self.config.get(CONF_ACCESS_TOKEN),
|
||||
"endpoint": desired_api_endpoint,
|
||||
"blink": self.options.get(CONF_BLINK, True),
|
||||
"discovery": self.options.get(CONF_DISCOVERY, True),
|
||||
}
|
||||
|
||||
@callback
|
||||
def async_current_settings_payload(self):
|
||||
"""Return a dict of configuration currently stored on the device."""
|
||||
settings = self.status["settings"] or {}
|
||||
|
||||
return {
|
||||
"sensors": [
|
||||
{self.api_version: s[self.api_version]}
|
||||
for s in self.status.get("sensors")
|
||||
],
|
||||
"actuators": self.status.get("actuators"),
|
||||
"dht_sensors": self.status.get(CONF_DHT_SENSORS),
|
||||
"ds18b20_sensors": self.status.get(CONF_DS18B20_SENSORS),
|
||||
"auth_token": settings.get("token"),
|
||||
"endpoint": settings.get("endpoint"),
|
||||
"blink": settings.get(CONF_BLINK),
|
||||
"discovery": settings.get(CONF_DISCOVERY),
|
||||
}
|
||||
|
||||
async def async_sync_device_config(self):
|
||||
"""Sync the new zone configuration to the Konnected device if needed."""
|
||||
_LOGGER.debug(
|
||||
"Device %s settings payload: %s",
|
||||
self.device_id,
|
||||
self.async_desired_settings_payload(),
|
||||
)
|
||||
if (
|
||||
self.async_desired_settings_payload()
|
||||
!= self.async_current_settings_payload()
|
||||
):
|
||||
_LOGGER.debug("Pushing settings to device %s", self.device_id)
|
||||
await self.client.put_settings(**self.async_desired_settings_payload())
|
||||
|
||||
|
||||
async def get_status(hass, host, port):
|
||||
"""Get the status of a Konnected Panel."""
|
||||
client = konnected.Client(
|
||||
host, str(port), aiohttp_client.async_get_clientsession(hass)
|
||||
)
|
||||
try:
|
||||
return await client.get_status()
|
||||
|
||||
except client.ClientError as err:
|
||||
_LOGGER.error("Exception trying to get panel status: %s", err)
|
||||
raise CannotConnect from err
|
||||
@@ -1,141 +0,0 @@
|
||||
"""Support for DHT and DS18B20 sensors attached to a Konnected device."""
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICES,
|
||||
CONF_NAME,
|
||||
CONF_SENSORS,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
PERCENTAGE,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, SIGNAL_DS18B20_NEW
|
||||
|
||||
SENSOR_TYPES: dict[str, SensorEntityDescription] = {
|
||||
"temperature": SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"humidity": SensorEntityDescription(
|
||||
key="humidity",
|
||||
name="Humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors attached to a Konnected device from a config entry."""
|
||||
# Uses legacy hass.data[DOMAIN] pattern
|
||||
# pylint: disable-next=home-assistant-use-runtime-data
|
||||
data = hass.data[DOMAIN]
|
||||
device_id = config_entry.data["id"]
|
||||
|
||||
# Initialize all DHT sensors.
|
||||
dht_sensors = [
|
||||
sensor
|
||||
for sensor in data[CONF_DEVICES][device_id][CONF_SENSORS]
|
||||
if sensor[CONF_TYPE] == "dht"
|
||||
]
|
||||
entities = [
|
||||
KonnectedSensor(device_id, data=sensor_config, description=description)
|
||||
for sensor_config in dht_sensors
|
||||
for description in SENSOR_TYPES.values()
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@callback
|
||||
def async_add_ds18b20(attrs):
|
||||
"""Add new KonnectedSensor representing a ds18b20 sensor."""
|
||||
sensor_config = next(
|
||||
(
|
||||
s
|
||||
for s in data[CONF_DEVICES][device_id][CONF_SENSORS]
|
||||
if s[CONF_TYPE] == "ds18b20" and s[CONF_ZONE] == attrs.get(CONF_ZONE)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
KonnectedSensor(
|
||||
device_id,
|
||||
sensor_config,
|
||||
SENSOR_TYPES["temperature"],
|
||||
addr=attrs.get("addr"),
|
||||
initial_state=attrs.get("temp"),
|
||||
)
|
||||
],
|
||||
True,
|
||||
)
|
||||
|
||||
# DS18B20 sensors entities are initialized when they report for the first
|
||||
# time. Set up a listener for that signal from the Konnected component.
|
||||
async_dispatcher_connect(hass, SIGNAL_DS18B20_NEW, async_add_ds18b20)
|
||||
|
||||
|
||||
class KonnectedSensor(SensorEntity):
|
||||
"""Represents a Konnected DHT Sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id,
|
||||
data,
|
||||
description: SensorEntityDescription,
|
||||
addr=None,
|
||||
initial_state=None,
|
||||
) -> None:
|
||||
"""Initialize the entity for a single sensor_type."""
|
||||
self.entity_description = description
|
||||
self._addr = addr
|
||||
self._data = data
|
||||
self._zone_num = self._data.get(CONF_ZONE)
|
||||
self._attr_unique_id = addr or f"{device_id}-{self._zone_num}-{description.key}"
|
||||
|
||||
# set initial state if known at initialization
|
||||
self._attr_native_value = initial_state
|
||||
if initial_state:
|
||||
self._attr_native_value = round(float(initial_state), 1)
|
||||
|
||||
# set entity name if given
|
||||
if name := self._data.get(CONF_NAME):
|
||||
name += f" {description.name}"
|
||||
self._attr_name = name
|
||||
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)})
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Store entity_id and register state change callback."""
|
||||
entity_id_key = self._addr or self.entity_description.key
|
||||
self._data[entity_id_key] = self.entity_id
|
||||
async_dispatcher_connect(
|
||||
self.hass, f"konnected.{self.entity_id}.update", self.async_set_state
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_state(self, state):
|
||||
"""Update the sensor's state."""
|
||||
if self.entity_description.key == "humidity":
|
||||
self._attr_native_value = int(float(state))
|
||||
else:
|
||||
self._attr_native_value = round(float(state), 1)
|
||||
self.async_write_ha_state()
|
||||
@@ -1,115 +1,8 @@
|
||||
{
|
||||
"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%]",
|
||||
"not_konn_panel": "Not a recognized Konnected.io device",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected alarm panel settings.",
|
||||
"title": "Konnected device ready"
|
||||
},
|
||||
"import_confirm": {
|
||||
"description": "A Konnected alarm panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry.",
|
||||
"title": "Import Konnected device"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"description": "Please enter the host information for your Konnected panel."
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_firmware": {
|
||||
"description": "Konnected's integration is deprecated and Konnected strongly recommends migrating to their ESPHome based firmware and integration by following the guide at {kb_page_url}. After this migration, make sure you don't have any Konnected YAML configuration left in your configuration.yaml file and remove this integration from Home Assistant.",
|
||||
"title": "Konnected firmware is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]"
|
||||
},
|
||||
"error": {
|
||||
"bad_host": "Invalid custom API host URL"
|
||||
},
|
||||
"step": {
|
||||
"options_binary": {
|
||||
"data": {
|
||||
"inverse": "Invert the open/close state",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"type": "Binary sensor type"
|
||||
},
|
||||
"description": "{zone} options",
|
||||
"title": "Configure binary sensor"
|
||||
},
|
||||
"options_digital": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"poll_interval": "Poll interval (minutes)",
|
||||
"type": "Sensor type"
|
||||
},
|
||||
"description": "[%key:component::konnected::options::step::options_binary::description%]",
|
||||
"title": "Configure digital sensor"
|
||||
},
|
||||
"options_io": {
|
||||
"data": {
|
||||
"1": "Zone 1",
|
||||
"2": "Zone 2",
|
||||
"3": "Zone 3",
|
||||
"4": "Zone 4",
|
||||
"5": "Zone 5",
|
||||
"6": "Zone 6",
|
||||
"7": "Zone 7",
|
||||
"out": "OUT"
|
||||
},
|
||||
"description": "Discovered a {model} at {host}. Select the base configuration of each I/O below - depending on the I/O it may allow for binary sensors (open/close contacts), digital sensors (dht and ds18b20), or switchable outputs. You'll be able to configure detailed options in the next steps.",
|
||||
"title": "Configure I/O"
|
||||
},
|
||||
"options_io_ext": {
|
||||
"data": {
|
||||
"8": "Zone 8",
|
||||
"9": "Zone 9",
|
||||
"10": "Zone 10",
|
||||
"11": "Zone 11",
|
||||
"12": "Zone 12",
|
||||
"alarm1": "ALARM1",
|
||||
"alarm2_out2": "OUT2/ALARM2",
|
||||
"out1": "OUT1"
|
||||
},
|
||||
"description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.",
|
||||
"title": "Configure extended I/O"
|
||||
},
|
||||
"options_misc": {
|
||||
"data": {
|
||||
"api_host": "Custom API host URL",
|
||||
"blink": "Blink panel LED on when sending state change",
|
||||
"discovery": "Respond to discovery requests on your network",
|
||||
"override_api_host": "Override default Home Assistant API host URL"
|
||||
},
|
||||
"description": "Please select the desired behavior for your panel",
|
||||
"title": "Configure misc"
|
||||
},
|
||||
"options_switch": {
|
||||
"data": {
|
||||
"activation": "Output when on",
|
||||
"momentary": "Pulse duration (ms)",
|
||||
"more_states": "Configure additional states for this zone",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"pause": "Pause between pulses (ms)",
|
||||
"repeat": "Times to repeat (-1=infinite)"
|
||||
},
|
||||
"description": "{zone} options: state {state}",
|
||||
"title": "Configure switchable output"
|
||||
}
|
||||
"integration_removed": {
|
||||
"description": "The Konnected.io (Legacy) integration relied on Konnected's deprecated firmware and has been removed from Home Assistant. Konnected recommends migrating to their ESPHome based firmware and the corresponding Home Assistant integration by following the [migration guide]({kb_page_url}).\n\nTo resolve this issue, migrate your Konnected device(s) to the ESPHome based firmware, then remove any `konnected:` YAML configuration from your `configuration.yaml` file, and remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Konnected.io (Legacy) integration entries]({entries}).",
|
||||
"title": "The Konnected.io (Legacy) integration has been removed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Support for wired switches attached to a Konnected device."""
|
||||
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_STATE,
|
||||
CONF_DEVICES,
|
||||
CONF_NAME,
|
||||
CONF_REPEAT,
|
||||
CONF_SWITCHES,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
DOMAIN,
|
||||
STATE_HIGH,
|
||||
STATE_LOW,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up switches attached to a Konnected device from a config entry."""
|
||||
data = hass.data[DOMAIN]
|
||||
device_id = config_entry.data["id"]
|
||||
switches = [
|
||||
KonnectedSwitch(device_id, zone_data.get(CONF_ZONE), zone_data)
|
||||
for zone_data in data[CONF_DEVICES][device_id][CONF_SWITCHES]
|
||||
]
|
||||
async_add_entities(switches)
|
||||
|
||||
|
||||
class KonnectedSwitch(SwitchEntity):
|
||||
"""Representation of a Konnected switch."""
|
||||
|
||||
def __init__(self, device_id, zone_num, data):
|
||||
"""Initialize the Konnected switch."""
|
||||
self._data = data
|
||||
self._device_id = device_id
|
||||
self._zone_num = zone_num
|
||||
self._activation = self._data.get(CONF_ACTIVATION, STATE_HIGH)
|
||||
self._momentary = self._data.get(CONF_MOMENTARY)
|
||||
self._pause = self._data.get(CONF_PAUSE)
|
||||
self._repeat = self._data.get(CONF_REPEAT)
|
||||
self._attr_is_on = self._boolean_state(self._data.get(ATTR_STATE))
|
||||
self._attr_name = self._data.get(CONF_NAME)
|
||||
self._attr_unique_id = (
|
||||
f"{device_id}-{self._zone_num}-{self._momentary}-"
|
||||
f"{self._pause}-{self._repeat}"
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)})
|
||||
|
||||
@property
|
||||
def panel(self):
|
||||
"""Return the Konnected HTTP client."""
|
||||
device_data = self.hass.data[DOMAIN][CONF_DEVICES][self._device_id]
|
||||
return device_data.get("panel")
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether the panel is available."""
|
||||
return self.panel.available
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Send a command to turn on the switch."""
|
||||
resp = await self.panel.update_switch(
|
||||
self._zone_num,
|
||||
int(self._activation == STATE_HIGH),
|
||||
self._momentary,
|
||||
self._repeat,
|
||||
self._pause,
|
||||
)
|
||||
|
||||
if resp.get(ATTR_STATE) is not None:
|
||||
self._set_state(True)
|
||||
|
||||
if self._momentary and resp.get(ATTR_STATE) != -1:
|
||||
# Immediately set the state back off for momentary switches
|
||||
self._set_state(False)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Send a command to turn off the switch."""
|
||||
resp = await self.panel.update_switch(
|
||||
self._zone_num, int(self._activation == STATE_LOW)
|
||||
)
|
||||
|
||||
if resp.get(ATTR_STATE) is not None:
|
||||
self._set_state(self._boolean_state(resp.get(ATTR_STATE)))
|
||||
|
||||
def _boolean_state(self, int_state: int | None) -> bool | None:
|
||||
if int_state == 0:
|
||||
return self._activation == STATE_LOW
|
||||
if int_state == 1:
|
||||
return self._activation == STATE_HIGH
|
||||
return None
|
||||
|
||||
def _set_state(self, state):
|
||||
self._attr_is_on = state
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(
|
||||
"Setting status of %s actuator zone %s to %s",
|
||||
self._device_id,
|
||||
self.name,
|
||||
state,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_state(self, state):
|
||||
"""Update the switch state."""
|
||||
self._set_state(state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Store entity_id and register state change callback."""
|
||||
self._data["entity_id"] = self.entity_id
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, f"konnected.{self.entity_id}.update", self.async_set_state
|
||||
)
|
||||
)
|
||||
@@ -28,25 +28,25 @@
|
||||
"ice_maker": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-outline-off"
|
||||
"off": "mdi:cube-off-outline"
|
||||
}
|
||||
},
|
||||
"ice_maker_bottom_zone": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-outline-off"
|
||||
"off": "mdi:cube-off-outline"
|
||||
}
|
||||
},
|
||||
"ice_maker_middle_zone": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-outline-off"
|
||||
"off": "mdi:cube-off-outline"
|
||||
}
|
||||
},
|
||||
"ice_maker_top_zone": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-outline-off"
|
||||
"off": "mdi:cube-off-outline"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -241,7 +241,7 @@ def preprocess_turn_on_alternatives(
|
||||
|
||||
if (color_name := params.pop(ATTR_COLOR_NAME, None)) is not None:
|
||||
try:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)
|
||||
params[ATTR_RGB_COLOR] = tuple(color_util.color_name_to_rgb(color_name))
|
||||
except ValueError:
|
||||
_LOGGER.warning("Got unknown color %s, falling back to white", color_name)
|
||||
params[ATTR_RGB_COLOR] = (255, 255, 255)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED
|
||||
from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED, BridgeResponseError
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -131,7 +131,11 @@ class LutronCasetaBatterySensor(LutronCasetaEntity, BinarySensorEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Fetch the latest battery status from the bridge."""
|
||||
status = await self._smartbridge.get_battery_status(self.device_id)
|
||||
try:
|
||||
status = await self._smartbridge.get_battery_status(self.device_id)
|
||||
except BridgeResponseError:
|
||||
self._attr_is_on = None
|
||||
return
|
||||
normalized_status = status.strip().casefold() if status else None
|
||||
if normalized_status == BATTERY_STATUS_LOW:
|
||||
self._attr_is_on = True
|
||||
|
||||
@@ -60,7 +60,7 @@ def get_matter_device_info(
|
||||
return None
|
||||
|
||||
return MatterDeviceInfo(
|
||||
unique_id=node.device_info.uniqueID,
|
||||
unique_id=node.device_info.uniqueID or "",
|
||||
vendor_id=hex(node.device_info.vendorID),
|
||||
product_id=hex(node.device_info.productID),
|
||||
)
|
||||
|
||||
@@ -6,13 +6,14 @@ from typing import Any
|
||||
from chip.clusters import Objects
|
||||
from matter_server.common.helpers.util import dataclass_to_dict, parse_attribute_path
|
||||
|
||||
from homeassistant.components.diagnostics import REDACTED
|
||||
from homeassistant.components.diagnostics import REDACTED, async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .helpers import MatterConfigEntry, get_matter, get_node_from_device_entry
|
||||
|
||||
ATTRIBUTES_TO_REDACT = {Objects.BasicInformation.Attributes.Location}
|
||||
SERVER_INFO_TO_REDACT = {"wifi_ssid"}
|
||||
|
||||
|
||||
def redact_matter_attributes(node_data: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -44,6 +45,7 @@ async def async_get_config_entry_diagnostics(
|
||||
matter = get_matter(hass)
|
||||
server_diagnostics = await matter.matter_client.get_diagnostics()
|
||||
data = dataclass_to_dict(server_diagnostics)
|
||||
data["info"] = async_redact_data(data["info"], SERVER_INFO_TO_REDACT)
|
||||
nodes = [redact_matter_attributes(node_data) for node_data in data["nodes"]]
|
||||
data["nodes"] = nodes
|
||||
|
||||
@@ -59,7 +61,9 @@ async def async_get_device_diagnostics(
|
||||
node = get_node_from_device_entry(hass, device)
|
||||
|
||||
return {
|
||||
"server_info": dataclass_to_dict(server_diagnostics.info),
|
||||
"server_info": async_redact_data(
|
||||
dataclass_to_dict(server_diagnostics.info), SERVER_INFO_TO_REDACT
|
||||
),
|
||||
"node": redact_matter_attributes(
|
||||
remove_serialization_type(dataclass_to_dict(node.node_data) if node else {})
|
||||
),
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"default": "mdi:home-lightning-bolt"
|
||||
},
|
||||
"eve_weather_trend": {
|
||||
"default": "mdi:weather",
|
||||
"default": "mdi:weather-cloudy",
|
||||
"state": {
|
||||
"cloudy": "mdi:weather-cloudy",
|
||||
"rainy": "mdi:weather-rainy",
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["matter-python-client==0.6.0"],
|
||||
"requirements": ["matter-python-client==0.7.1"],
|
||||
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, llm
|
||||
|
||||
from .application_credentials import authorization_server_context
|
||||
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
|
||||
from .const import CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
|
||||
from .coordinator import ModelContextProtocolCoordinator, TokenManager
|
||||
from .types import ModelContextProtocolConfigEntry
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from yarl import URL
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_TOKEN, CONF_URL
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -24,13 +24,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
|
||||
from . import async_get_config_entry_implementation
|
||||
from .application_credentials import authorization_server_context
|
||||
from .const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_AUTHORIZATION_URL,
|
||||
CONF_SCOPE,
|
||||
CONF_TOKEN_URL,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN
|
||||
from .coordinator import TokenManager, mcp_client
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
DOMAIN = "mcp"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_ACCESS_TOKEN = "access_token"
|
||||
CONF_AUTHORIZATION_URL = "authorization_url"
|
||||
CONF_TOKEN_URL = "token_url"
|
||||
CONF_SCOPE = "scope"
|
||||
|
||||
@@ -41,7 +41,7 @@ from mcp.shared.message import SessionMessage
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.helpers import llm
|
||||
|
||||
@@ -56,10 +56,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
STREAMABLE_API = "/api/mcp"
|
||||
TIMEOUT = 60 # Seconds
|
||||
|
||||
# Content types
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONTENT_TYPE_JSON = "application/json"
|
||||
|
||||
# Legacy SSE endpoint
|
||||
SSE_API = f"/{DOMAIN}/sse"
|
||||
MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}"
|
||||
|
||||
@@ -108,6 +108,7 @@ ABBREVIATIONS = {
|
||||
"mode_stat_t": "mode_state_topic",
|
||||
"mode_stat_tpl": "mode_state_template",
|
||||
"modes": "modes",
|
||||
"msg_exp_int": "message_expiry_interval",
|
||||
"name": "name",
|
||||
"o": "origin",
|
||||
"off_dly": "off_delay",
|
||||
|
||||
@@ -120,6 +120,8 @@ from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
DurationSelector,
|
||||
DurationSelectorConfig,
|
||||
FileSelector,
|
||||
FileSelectorConfig,
|
||||
NumberSelector,
|
||||
@@ -227,6 +229,7 @@ from .const import (
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE,
|
||||
CONF_MAX,
|
||||
CONF_MAX_KELVIN,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_MIN,
|
||||
CONF_MIN_KELVIN,
|
||||
CONF_MODE_COMMAND_TEMPLATE,
|
||||
@@ -3721,6 +3724,11 @@ MQTT_DEVICE_PLATFORM_FIELDS = {
|
||||
default=DEFAULT_QOS,
|
||||
section="mqtt_settings",
|
||||
),
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL: PlatformField(
|
||||
selector=DurationSelector(DurationSelectorConfig(enable_day=True)),
|
||||
required=False,
|
||||
section="mqtt_settings",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ CONF_IMAGE_TOPIC = "image_topic"
|
||||
CONF_JSON_ATTRS_TOPIC = "json_attributes_topic"
|
||||
CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template"
|
||||
CONF_KEEPALIVE = "keepalive"
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL = "message_expiry_interval"
|
||||
CONF_ORIGIN = "origin"
|
||||
CONF_QOS = ATTR_QOS
|
||||
CONF_RETAIN = ATTR_RETAIN
|
||||
|
||||
@@ -17,7 +17,7 @@ from .models import DATA_MQTT, PublishPayloadType
|
||||
STORED_MESSAGES = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TimestampedPublishMessage:
|
||||
"""MQTT Message."""
|
||||
|
||||
@@ -26,6 +26,8 @@ class TimestampedPublishMessage:
|
||||
qos: int
|
||||
retain: bool
|
||||
timestamp: float
|
||||
encoding: str | None
|
||||
kwargs: dict[str, Any]
|
||||
|
||||
|
||||
def log_message(
|
||||
@@ -35,6 +37,8 @@ def log_message(
|
||||
payload: PublishPayloadType,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
encoding: str | None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Log an outgoing MQTT message."""
|
||||
entity_info = hass.data[DATA_MQTT].debug_info_entities.setdefault(
|
||||
@@ -45,7 +49,13 @@ def log_message(
|
||||
"messages": deque(maxlen=STORED_MESSAGES),
|
||||
}
|
||||
msg = TimestampedPublishMessage(
|
||||
topic, payload, qos, retain, timestamp=time.monotonic()
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
timestamp=time.monotonic(),
|
||||
encoding=encoding,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
entity_info["transmitted"][topic]["messages"].append(msg)
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ from .const import (
|
||||
CONF_JSON_ATTRS_TEMPLATE,
|
||||
CONF_JSON_ATTRS_TOPIC,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_QOS,
|
||||
@@ -94,7 +95,6 @@ from .const import (
|
||||
CONF_SW_VERSION,
|
||||
CONF_TOPIC,
|
||||
CONF_VIA_DEVICE,
|
||||
DEFAULT_ENCODING,
|
||||
DOMAIN,
|
||||
MQTT_CONNECTION_STATE,
|
||||
)
|
||||
@@ -153,6 +153,8 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"unit_of_measurement",
|
||||
}
|
||||
|
||||
PUBLISH_KWARGS = (CONF_MESSAGE_EXPIRY_INTERVAL,)
|
||||
|
||||
|
||||
@callback
|
||||
def async_handle_schema_error(
|
||||
@@ -1539,36 +1541,20 @@ class MqttEntity(
|
||||
await MqttDiscoveryUpdateMixin.async_will_remove_from_hass(self)
|
||||
debug_info.remove_entity_data(self.hass, self.entity_id)
|
||||
|
||||
async def async_publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: PublishPayloadType,
|
||||
qos: int = 0,
|
||||
retain: bool = False,
|
||||
encoding: str | None = DEFAULT_ENCODING,
|
||||
) -> None:
|
||||
"""Publish message to an MQTT topic."""
|
||||
log_message(self.hass, self.entity_id, topic, payload, qos, retain)
|
||||
await async_publish(
|
||||
self.hass,
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
encoding,
|
||||
)
|
||||
|
||||
async def async_publish_with_config(
|
||||
self, topic: str, payload: PublishPayloadType
|
||||
) -> None:
|
||||
"""Publish payload to a topic using config."""
|
||||
await self.async_publish(
|
||||
topic,
|
||||
payload,
|
||||
self._config[CONF_QOS],
|
||||
self._config[CONF_RETAIN],
|
||||
self._config[CONF_ENCODING],
|
||||
kwargs: dict[str, Any] = {
|
||||
key: value for key, value in self._config.items() if key in PUBLISH_KWARGS
|
||||
}
|
||||
qos: int = self._config[CONF_QOS]
|
||||
retain: bool = self._config[CONF_RETAIN]
|
||||
encoding: str = self._config[CONF_ENCODING]
|
||||
log_message(
|
||||
self.hass, self.entity_id, topic, payload, qos, retain, encoding, **kwargs
|
||||
)
|
||||
await async_publish(self.hass, topic, payload, qos, retain, encoding, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
|
||||
@@ -509,10 +509,20 @@ class MqttComponentConfig:
|
||||
discovery_payload: MQTTDiscoveryPayload
|
||||
|
||||
|
||||
class MessageExpiryInterval(TypedDict, total=False):
|
||||
"""Hold the Message Expiry Interval."""
|
||||
|
||||
days: float
|
||||
hours: float
|
||||
minutes: float
|
||||
seconds: float
|
||||
|
||||
|
||||
class DeviceMqttOptions(TypedDict, total=False):
|
||||
"""Hold the shared MQTT specific options for an MQTT device."""
|
||||
|
||||
qos: int
|
||||
message_expiry_interval: MessageExpiryInterval
|
||||
|
||||
|
||||
class MqttDeviceData(TypedDict, total=False):
|
||||
|
||||
@@ -40,6 +40,7 @@ from .const import (
|
||||
CONF_JSON_ATTRS_TEMPLATE,
|
||||
CONF_JSON_ATTRS_TOPIC,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_ORIGIN,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
@@ -66,6 +67,7 @@ SHARED_OPTIONS = [
|
||||
CONF_AVAILABILITY_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_STATE_TOPIC,
|
||||
@@ -161,6 +163,14 @@ MQTT_ORIGIN_INFO_SCHEMA = vol.All(
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def valid_message_expiry_interval(value: Any) -> int:
|
||||
"""Return Message Expiry Interval in seconds."""
|
||||
if isinstance(value, int):
|
||||
return cv.positive_int(value) # type: ignore[no-any-return]
|
||||
return int(cv.positive_time_period_dict(value).total_seconds())
|
||||
|
||||
|
||||
MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA,
|
||||
@@ -172,6 +182,7 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string,
|
||||
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
}
|
||||
)
|
||||
@@ -203,6 +214,7 @@ DEVICE_DISCOVERY_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
vol.Required(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA,
|
||||
vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
|
||||
vol.Optional(CONF_QOS): valid_qos_schema,
|
||||
vol.Optional(CONF_ENCODING): cv.string,
|
||||
}
|
||||
|
||||
@@ -197,9 +197,11 @@
|
||||
},
|
||||
"mqtt_settings": {
|
||||
"data": {
|
||||
"message_expiry_interval": "Message Expiry Interval",
|
||||
"qos": "QoS"
|
||||
},
|
||||
"data_description": {
|
||||
"message_expiry_interval": "Retention time interval for published message.",
|
||||
"qos": "The Quality of Service value the device's entities should use."
|
||||
},
|
||||
"name": "MQTT settings"
|
||||
|
||||
@@ -10,6 +10,7 @@ from pyatmo.event import Event as NaEvent
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import Camera, CameraEntityFeature
|
||||
from homeassistant.const import ATTR_PERSONS
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
@@ -20,7 +21,6 @@ from .const import (
|
||||
ATTR_CAMERA_LIGHT_MODE,
|
||||
ATTR_EVENT_TYPE,
|
||||
ATTR_PERSON,
|
||||
ATTR_PERSONS,
|
||||
CAMERA_LIGHT_MODES,
|
||||
CAMERA_TRIGGERS,
|
||||
CONF_URL_SECURITY,
|
||||
|
||||
@@ -92,8 +92,6 @@ ATTR_HOME_ID = "home_id"
|
||||
ATTR_HOME_NAME = "home_name"
|
||||
ATTR_IS_KNOWN = "is_known"
|
||||
ATTR_PERSON = "person"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_PERSONS = "persons"
|
||||
ATTR_PSEUDO = "pseudo"
|
||||
ATTR_SCHEDULE_ID = "schedule_id"
|
||||
ATTR_SCHEDULE_NAME = "schedule_name"
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
from aiohttp.web import Request
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME, ATTR_PERSONS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
@@ -14,7 +14,6 @@ from .const import (
|
||||
ATTR_FACE_URL,
|
||||
ATTR_HOME_ID,
|
||||
ATTR_IS_KNOWN,
|
||||
ATTR_PERSONS,
|
||||
DATA_DEVICE_IDS,
|
||||
DATA_PERSONS,
|
||||
DEFAULT_PERSON,
|
||||
|
||||
@@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import ATTR_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -14,7 +15,6 @@ from .const import (
|
||||
ATTR_DESCRIPTION,
|
||||
ATTR_EXPIRES,
|
||||
ATTR_HEADLINE,
|
||||
ATTR_ID,
|
||||
ATTR_RECOMMENDED_ACTIONS,
|
||||
ATTR_SENDER,
|
||||
ATTR_SENT,
|
||||
|
||||
@@ -29,8 +29,6 @@ ATTR_SEVERITY: str = "severity"
|
||||
ATTR_RECOMMENDED_ACTIONS: str = "recommended_actions"
|
||||
ATTR_AFFECTED_AREAS: str = "affected_areas"
|
||||
ATTR_WEB: str = "web"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_ID: str = "id"
|
||||
ATTR_SENT: str = "sent"
|
||||
ATTR_START: str = "start"
|
||||
ATTR_EXPIRES: str = "expires"
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_DEVICE, CONF_HOST
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -47,9 +47,6 @@ from .util import get_meaning
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE = "device"
|
||||
|
||||
INPUT_SOURCES_DEFAULT: list[InputSource] = []
|
||||
LISTENING_MODES_DEFAULT: list[ListeningMode] = []
|
||||
INPUT_SOURCES_ALL_MEANINGS = {
|
||||
|
||||
@@ -595,8 +595,8 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
)
|
||||
)
|
||||
|
||||
if "reasoning" not in model_args:
|
||||
# Reasoning models handle this correctly with just a prompt
|
||||
if not model_args["model"].startswith("o"):
|
||||
# o-series models handle this correctly with just a prompt
|
||||
remove_citations = True
|
||||
|
||||
tools.append(web_search)
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["opendisplay"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["py-opendisplay==5.9.0"]
|
||||
"requirements": ["py-opendisplay==7.2.3"]
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ async def _async_upload_image(call: ServiceCall) -> None:
|
||||
pil_image,
|
||||
refresh_mode=refresh_mode,
|
||||
dither_mode=dither_mode,
|
||||
tone_compression=tone_compression,
|
||||
tone=tone_compression,
|
||||
fit=fit_mode,
|
||||
rotate=rotation,
|
||||
)
|
||||
|
||||
@@ -37,11 +37,15 @@ class OpenhomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.debug("async_step_ssdp: Incomplete discovery, ignoring")
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
|
||||
_LOGGER.debug(
|
||||
"async_step_ssdp: setting unique id %s", discovery_info.upnp[ATTR_UPNP_UDN]
|
||||
)
|
||||
udn = discovery_info.upnp[ATTR_UPNP_UDN]
|
||||
if isinstance(udn, list):
|
||||
if not udn:
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
udn = udn[0]
|
||||
|
||||
await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN])
|
||||
_LOGGER.debug("async_step_ssdp: setting unique id %s", udn)
|
||||
|
||||
await self.async_set_unique_id(udn)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: discovery_info.ssdp_location})
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Failed to connect to OpenRGB SDK server {server_address}: {error}"
|
||||
},
|
||||
"communication_error": {
|
||||
"message": "Failed to communicate with OpenRGB SDK server {server_address}: {error}"
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.components.water_heater import (
|
||||
WaterHeaterEntity,
|
||||
WaterHeaterEntityFeature,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -45,10 +45,6 @@ SERVICE_GET_PROFILE = "get_profile"
|
||||
SERVICE_SET_PROFILE = "set_profile"
|
||||
SERVICE_SET_V40MIN = "set_v40_min"
|
||||
SERVICE_TURN_AWAY_MODE_ON = "turn_away_mode_on"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_TURN_OFF = "turn_off"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_TURN_ON = "turn_on"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -429,6 +429,9 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
|
||||
close_command=OverkizCommand.CLOSE,
|
||||
stop_command=OverkizCommand.STOP,
|
||||
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
|
||||
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
|
||||
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
|
||||
stop_tilt_command=OverkizCommand.STOP,
|
||||
),
|
||||
OverkizCoverDescription(
|
||||
key=UIClass.ROLLER_SHUTTER,
|
||||
|
||||
@@ -118,6 +118,9 @@
|
||||
"services": {
|
||||
"prune_images": {
|
||||
"service": "mdi:delete-sweep"
|
||||
},
|
||||
"recreate_container": {
|
||||
"service": "mdi:restart"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ from .coordinator import PortainerConfigEntry
|
||||
|
||||
ATTR_DATE_UNTIL = "until"
|
||||
ATTR_DANGLING = "dangling"
|
||||
ATTR_TIMEOUT = "timeout"
|
||||
ATTR_PULL_IMAGE = "pull_image"
|
||||
ATTR_CONTAINER_DEVICE_ID = "container_device_id"
|
||||
|
||||
SERVICE_PRUNE_IMAGES = "prune_images"
|
||||
SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
|
||||
@@ -32,6 +35,17 @@ SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
|
||||
},
|
||||
)
|
||||
|
||||
SERVICE_RECREATE_CONTAINER = "recreate_container"
|
||||
SERVICE_RECREATE_CONTAINER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONTAINER_DEVICE_ID): cv.string,
|
||||
vol.Optional(ATTR_TIMEOUT): vol.All(
|
||||
cv.time_period, vol.Range(min=timedelta(minutes=1))
|
||||
),
|
||||
vol.Optional(ATTR_PULL_IMAGE): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _extract_config_entry(service_call: ServiceCall) -> PortainerConfigEntry:
|
||||
"""Extract config entry from the service call."""
|
||||
@@ -75,6 +89,45 @@ async def _get_endpoint_id(
|
||||
return endpoint_data.endpoint.id
|
||||
|
||||
|
||||
async def _get_container_and_endpoint_ids(
|
||||
call: ServiceCall,
|
||||
) -> tuple[PortainerConfigEntry, int, str]:
|
||||
"""Get config entry, endpoint ID and container ID from the container device ID."""
|
||||
device_reg = dr.async_get(call.hass)
|
||||
device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID])
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
config_entry: PortainerConfigEntry | None = None
|
||||
for loaded_entry in call.hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
if loaded_entry.entry_id in device.config_entries:
|
||||
config_entry = loaded_entry
|
||||
break
|
||||
|
||||
if config_entry is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
for data in coordinator.data.values():
|
||||
for container_name, container_data in data.containers.items():
|
||||
if (
|
||||
DOMAIN,
|
||||
f"{config_entry.entry_id}_{data.endpoint.id}_{container_name}",
|
||||
) in device.identifiers:
|
||||
return config_entry, data.endpoint.id, container_data.container.id
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
|
||||
async def prune_images(call: ServiceCall) -> None:
|
||||
"""Prune unused images in Portainer, with more controls."""
|
||||
config_entry = await _extract_config_entry(call)
|
||||
@@ -104,6 +157,40 @@ async def prune_images(call: ServiceCall) -> None:
|
||||
) from err
|
||||
|
||||
|
||||
async def recreate_container(call: ServiceCall) -> None:
|
||||
"""Recreate a container in Portainer, with more controls."""
|
||||
config_entry, endpoint_id, container_id = await _get_container_and_endpoint_ids(
|
||||
call
|
||||
)
|
||||
coordinator = config_entry.runtime_data
|
||||
timeout: timedelta | None = call.data.get(ATTR_TIMEOUT)
|
||||
|
||||
try:
|
||||
await coordinator.portainer.container_recreate(
|
||||
endpoint_id=endpoint_id,
|
||||
container_id=container_id,
|
||||
**({"timeout": timeout} if timeout is not None else {}),
|
||||
pull_image=call.data.get(ATTR_PULL_IMAGE, False),
|
||||
)
|
||||
except PortainerAuthenticationError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth_no_details",
|
||||
) from err
|
||||
except PortainerConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_no_details",
|
||||
) from err
|
||||
except PortainerTimeoutError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connect_no_details",
|
||||
) from err
|
||||
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
|
||||
@@ -113,3 +200,10 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
prune_images,
|
||||
SERVICE_PRUNE_IMAGES_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
recreate_container,
|
||||
SERVICE_RECREATE_CONTAINER_SCHEMA,
|
||||
)
|
||||
|
||||
@@ -16,3 +16,20 @@ prune_images:
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
recreate_container:
|
||||
fields:
|
||||
container_device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: portainer
|
||||
model: Container
|
||||
timeout:
|
||||
required: false
|
||||
selector:
|
||||
duration:
|
||||
pull_image:
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
@@ -235,6 +235,24 @@
|
||||
}
|
||||
},
|
||||
"name": "Prune unused images"
|
||||
},
|
||||
"recreate_container": {
|
||||
"description": "Recreates a container on a Portainer endpoint. This is more disruptive than a restart as the container will be stopped, removed, and then re-created with the same configuration. Use with caution.",
|
||||
"fields": {
|
||||
"container_device_id": {
|
||||
"description": "The container to recreate.",
|
||||
"name": "Container"
|
||||
},
|
||||
"pull_image": {
|
||||
"description": "Whether to pull the image before recreating the container. This can be used to update the container to the latest version of the image.",
|
||||
"name": "Pull image"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "The time to wait for the container to stop before killing it. If not provided, a default of 5 minutes will be used.",
|
||||
"name": "Timeout"
|
||||
}
|
||||
},
|
||||
"name": "Recreate container"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
|
||||
@@ -19,7 +19,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE
|
||||
from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -70,8 +70,6 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
DEFAULT_MAX_OBJECTS = 5
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_ENABLED = "enabled"
|
||||
CONF_SECONDS = "seconds"
|
||||
CONF_MAX_OBJECTS = "max_objects"
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_CONDITION,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_PASSWORD,
|
||||
@@ -67,8 +68,6 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_CONDITION = "condition"
|
||||
CONF_DEWPOINT = "dewpoint"
|
||||
CONF_ET = "et"
|
||||
CONF_MAXRH = "maxrh"
|
||||
|
||||
@@ -29,29 +29,29 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"translation_key_0": {
|
||||
"default": "mdi:abc"
|
||||
"flow_sensor_clicks_cubic_meter": {
|
||||
"default": "mdi:water-pump"
|
||||
},
|
||||
"translation_key_1": {
|
||||
"default": "mdi:abc"
|
||||
"flow_sensor_consumed_liters": {
|
||||
"default": "mdi:water-pump"
|
||||
},
|
||||
"translation_key_2": {
|
||||
"default": "mdi:abc"
|
||||
"flow_sensor_leak_clicks": {
|
||||
"default": "mdi:pipe-leak"
|
||||
},
|
||||
"translation_key_3": {
|
||||
"default": "mdi:abc"
|
||||
"flow_sensor_leak_volume": {
|
||||
"default": "mdi:pipe-leak"
|
||||
},
|
||||
"translation_key_4": {
|
||||
"default": "mdi:abc"
|
||||
"flow_sensor_start_index": {
|
||||
"default": "mdi:water-pump"
|
||||
},
|
||||
"translation_key_5": {
|
||||
"default": "mdi:abc"
|
||||
"flow_sensor_watering_clicks": {
|
||||
"default": "mdi:water-pump"
|
||||
},
|
||||
"translation_key_6": {
|
||||
"default": "mdi:abc"
|
||||
"last_leak_detected": {
|
||||
"default": "mdi:pipe-leak"
|
||||
},
|
||||
"translation_key_7": {
|
||||
"default": "mdi:abc"
|
||||
"rain_sensor_rain_start": {
|
||||
"default": "mdi:weather-pouring"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.19.1"]
|
||||
"requirements": ["reolink-aio==0.20.0"]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
)
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.const import CONF_REGION, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -38,7 +38,6 @@ from . import RoborockConfigEntry
|
||||
from .const import (
|
||||
CONF_BASE_URL,
|
||||
CONF_ENTRY_CODE,
|
||||
CONF_REGION,
|
||||
CONF_SHOW_BACKGROUND,
|
||||
CONF_SHOW_ROOMS,
|
||||
CONF_SHOW_WALLS,
|
||||
|
||||
@@ -13,8 +13,6 @@ CONF_USER_DATA = "user_data"
|
||||
CONF_SHOW_BACKGROUND = "show_background"
|
||||
CONF_SHOW_WALLS = "show_walls"
|
||||
CONF_SHOW_ROOMS = "show_rooms"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_REGION = "region"
|
||||
REGION_OPTIONS = ["auto", "us", "eu", "ru", "cn"]
|
||||
|
||||
# Option Flow steps
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user