mirror of
https://github.com/home-assistant/core.git
synced 2026-05-23 01:05:20 +02:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf48806ee9 | |||
| 2c25c5ad26 | |||
| ff1177dde4 | |||
| 4306863729 | |||
| 1cc91cd3b6 | |||
| ecac38a359 | |||
| ba2f66e751 | |||
| 8301addc94 | |||
| 77bc932cf0 | |||
| 11903ac62e | |||
| 878761cb41 | |||
| d7bf7df59f | |||
| e5890172a0 | |||
| e9a58cdd20 | |||
| cab7c41a7f | |||
| 277a2d847a | |||
| 7835a4992a | |||
| 94581d8ab6 | |||
| 9dc37a2f46 | |||
| 7534c438c1 | |||
| 7d6ec7fc58 | |||
| 69efa8ee1a | |||
| f49de3548e | |||
| 305b5d6e00 | |||
| d94226260b | |||
| ecc8e52f3e | |||
| 5771b0c86c | |||
| 3e289da366 | |||
| 944fb1ef67 | |||
| 49ab42d3a2 | |||
| 383f6142f0 | |||
| 2f120cf604 | |||
| 1b6e9f5094 | |||
| b2257caeb7 | |||
| 0ec0ea30ac | |||
| 584b32c8b3 | |||
| 4033a8b83a | |||
| add8a5f799 | |||
| 7c137b5c73 | |||
| 4a6c5b5a22 | |||
| 1009ce4180 | |||
| 22fb68b7a1 | |||
| 81e06539e6 | |||
| 7c18b67b2e | |||
| a8bc244a7a | |||
| 5975f4b179 | |||
| 9ed16b63a3 | |||
| 8dadaa2f9e | |||
| 4f98c71586 |
@@ -917,12 +917,49 @@ jobs:
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore pytest test counts cache
|
||||
id: cache-pytest-counts
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: pytest_test_counts.json
|
||||
# Primary key is a sentinel; restore-keys pick the most recent
|
||||
# prefix match since the real (content-addressed) key isn't
|
||||
# known until split_tests.py runs below.
|
||||
key: >-
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}-restore-sentinel
|
||||
restore-keys: |
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }}-
|
||||
- name: Run split_tests.py
|
||||
env:
|
||||
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python -m script.split_tests ${TEST_GROUP_COUNT} tests
|
||||
python -m script.split_tests \
|
||||
--cache pytest_test_counts.json \
|
||||
${TEST_GROUP_COUNT} tests
|
||||
- name: Hash pytest test counts cache
|
||||
id: cache-pytest-counts-hash
|
||||
run: |
|
||||
echo "hash=$(sha256sum pytest_test_counts.json | cut -d' ' -f1)" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
- name: Save pytest test counts cache
|
||||
# Content-addressed key: identical content reuses the same entry.
|
||||
# Skip the save when the restore already matched that hash.
|
||||
if: >-
|
||||
!endsWith(
|
||||
steps.cache-pytest-counts.outputs.cache-matched-key,
|
||||
steps.cache-pytest-counts-hash.outputs.hash
|
||||
)
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: pytest_test_counts.json
|
||||
key: >-
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}-${{
|
||||
steps.cache-pytest-counts-hash.outputs.hash }}
|
||||
- name: Upload pytest_buckets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
Generated
+2
@@ -945,6 +945,8 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,53 +1,450 @@
|
||||
"""The Konnected.io integration."""
|
||||
"""Support for Konnected devices."""
|
||||
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
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.config_entries import ConfigEntry, ConfigEntryState
|
||||
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.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
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
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({DOMAIN: cv.match_all}, extra=vol.ALLOW_EXTRA)
|
||||
_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]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Konnected.io integration."""
|
||||
if DOMAIN in config:
|
||||
_create_issue(hass)
|
||||
"""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
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Konnected.io from a config entry."""
|
||||
_create_issue(hass)
|
||||
"""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))
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
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)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID])
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
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",
|
||||
},
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""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,11 +1,892 @@
|
||||
"""Config flow for Konnected.io integration."""
|
||||
"""Config flow for konnected.io integration."""
|
||||
# pylint: disable=home-assistant-config-flow-name-field # Name field is no longer allowed in config flow schemas
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .const import DOMAIN
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Konnected.io."""
|
||||
"""Handle a config flow for Konnected Panels."""
|
||||
|
||||
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,3 +1,46 @@
|
||||
"""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"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""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."""
|
||||
@@ -0,0 +1,57 @@
|
||||
"""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,9 +1,17 @@
|
||||
{
|
||||
"domain": "konnected",
|
||||
"name": "Konnected.io (Legacy)",
|
||||
"codeowners": [],
|
||||
"codeowners": ["@heythisisnate"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/konnected",
|
||||
"integration_type": "system",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"requirements": []
|
||||
"loggers": ["konnected"],
|
||||
"requirements": ["konnected==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "konnected.io"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""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
|
||||
@@ -0,0 +1,141 @@
|
||||
"""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,8 +1,115 @@
|
||||
{
|
||||
"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": {
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {})
|
||||
),
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""The System Bridge integration."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from systembridgeconnector.exceptions import (
|
||||
AuthenticationException,
|
||||
@@ -11,71 +9,34 @@ from systembridgeconnector.exceptions import (
|
||||
ConnectionErrorException,
|
||||
DataMissingException,
|
||||
)
|
||||
from systembridgeconnector.models.keyboard_key import KeyboardKey
|
||||
from systembridgeconnector.models.keyboard_text import KeyboardText
|
||||
from systembridgeconnector.models.modules.processes import Process
|
||||
from systembridgeconnector.models.open_path import OpenPath
|
||||
from systembridgeconnector.models.open_url import OpenUrl
|
||||
from systembridgeconnector.version import Version
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_COMMAND,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_PATH,
|
||||
CONF_PORT,
|
||||
CONF_TOKEN,
|
||||
CONF_URL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .config_flow import SystemBridgeConfigFlow
|
||||
from .const import DATA_WAIT_TIMEOUT, DOMAIN, MODULES
|
||||
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
|
||||
|
||||
|
||||
def _get_coordinator(
|
||||
hass: HomeAssistant, entry_id: str
|
||||
) -> SystemBridgeDataUpdateCoordinator:
|
||||
"""Return the coordinator for a config entry id."""
|
||||
entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
entry_id
|
||||
)
|
||||
if entry is None or entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": entry_id},
|
||||
)
|
||||
return entry.runtime_data
|
||||
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.MEDIA_PLAYER,
|
||||
@@ -84,26 +45,12 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
CONF_BRIDGE = "bridge"
|
||||
CONF_KEY = "key"
|
||||
CONF_TEXT = "text"
|
||||
|
||||
SERVICE_GET_PROCESS_BY_ID = "get_process_by_id"
|
||||
SERVICE_GET_PROCESSES_BY_NAME = "get_processes_by_name"
|
||||
SERVICE_OPEN_PATH = "open_path"
|
||||
SERVICE_POWER_COMMAND = "power_command"
|
||||
SERVICE_OPEN_URL = "open_url"
|
||||
SERVICE_SEND_KEYPRESS = "send_keypress"
|
||||
SERVICE_SEND_TEXT = "send_text"
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the System Bridge services."""
|
||||
|
||||
POWER_COMMAND_MAP = {
|
||||
"hibernate": "power_hibernate",
|
||||
"lock": "power_lock",
|
||||
"logout": "power_logout",
|
||||
"restart": "power_restart",
|
||||
"shutdown": "power_shutdown",
|
||||
"sleep": "power_sleep",
|
||||
}
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -231,219 +178,6 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL):
|
||||
return True
|
||||
|
||||
def valid_device(device: str) -> str:
|
||||
"""Check device is valid."""
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device)
|
||||
if device_entry is not None:
|
||||
try:
|
||||
return next(
|
||||
entry.entry_id
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.entry_id in device_entry.config_entries
|
||||
)
|
||||
except StopIteration as exception:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device},
|
||||
) from exception
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device},
|
||||
)
|
||||
|
||||
async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the get process by id service call."""
|
||||
_LOGGER.debug("Get process by id: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
processes: list[Process] = coordinator.data.processes
|
||||
|
||||
# Find process.id from list, raise ServiceValidationError if not found
|
||||
try:
|
||||
return asdict(
|
||||
next(
|
||||
process
|
||||
for process in processes
|
||||
if process.id == service_call.data[CONF_ID]
|
||||
)
|
||||
)
|
||||
except StopIteration as exception:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="process_not_found",
|
||||
translation_placeholders={"id": service_call.data[CONF_ID]},
|
||||
) from exception
|
||||
|
||||
async def handle_get_processes_by_name(
|
||||
service_call: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handle the get process by name service call."""
|
||||
_LOGGER.debug("Get process by name: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
|
||||
# Find processes from list
|
||||
items: list[dict[str, Any]] = [
|
||||
asdict(process)
|
||||
for process in coordinator.data.processes
|
||||
if process.name is not None
|
||||
and service_call.data[CONF_NAME].lower() in process.name.lower()
|
||||
]
|
||||
|
||||
return {
|
||||
"count": len(items),
|
||||
"processes": list(items),
|
||||
}
|
||||
|
||||
async def handle_open_path(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open path service call."""
|
||||
_LOGGER.debug("Open path: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_path(
|
||||
OpenPath(path=service_call.data[CONF_PATH])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
async def handle_power_command(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the power command service call."""
|
||||
_LOGGER.debug("Power command: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await getattr(
|
||||
coordinator.websocket_client,
|
||||
POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]],
|
||||
)()
|
||||
return asdict(response)
|
||||
|
||||
async def handle_open_url(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open url service call."""
|
||||
_LOGGER.debug("Open URL: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_url(
|
||||
OpenUrl(url=service_call.data[CONF_URL])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_keypress service call."""
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_keypress(
|
||||
KeyboardKey(key=service_call.data[CONF_KEY])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
async def handle_send_text(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_keypress service call."""
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_text(
|
||||
KeyboardText(text=service_call.data[CONF_TEXT])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_PROCESS_BY_ID,
|
||||
handle_get_process_by_id,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_ID): cv.positive_int,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_PROCESSES_BY_NAME,
|
||||
handle_get_processes_by_name,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_OPEN_PATH,
|
||||
handle_open_path,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_PATH): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_POWER_COMMAND,
|
||||
handle_power_command,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP),
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_OPEN_URL,
|
||||
handle_open_url,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_URL): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SEND_KEYPRESS,
|
||||
handle_send_keypress,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_KEY): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
description_placeholders={
|
||||
"syntax_keys_documentation_url": "http://robotjs.io/docs/syntax#keys"
|
||||
},
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SEND_TEXT,
|
||||
handle_send_text,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_TEXT): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# Reload entry when its updated.
|
||||
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Service registration for System Bridge integration."""
|
||||
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from systembridgeconnector.models.keyboard_key import KeyboardKey
|
||||
from systembridgeconnector.models.keyboard_text import KeyboardText
|
||||
from systembridgeconnector.models.modules.processes import Process
|
||||
from systembridgeconnector.models.open_path import OpenPath
|
||||
from systembridgeconnector.models.open_url import OpenUrl
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_COMMAND, CONF_ID, CONF_NAME, CONF_PATH, CONF_URL
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
service,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_BRIDGE = "bridge"
|
||||
CONF_KEY = "key"
|
||||
CONF_TEXT = "text"
|
||||
|
||||
POWER_COMMAND_MAP = {
|
||||
"hibernate": "power_hibernate",
|
||||
"lock": "power_lock",
|
||||
"logout": "power_logout",
|
||||
"restart": "power_restart",
|
||||
"shutdown": "power_shutdown",
|
||||
"sleep": "power_sleep",
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for System Bridge integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"get_process_by_id",
|
||||
handle_get_process_by_id,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_ID): cv.positive_int,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"get_processes_by_name",
|
||||
handle_get_processes_by_name,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"open_path",
|
||||
handle_open_path,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_PATH): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"power_command",
|
||||
handle_power_command,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP),
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"open_url",
|
||||
handle_open_url,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_URL): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"send_keypress",
|
||||
handle_send_keypress,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_KEY): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
description_placeholders={
|
||||
"syntax_keys_documentation_url": "https://robotjs.dev/docs/syntax#keys"
|
||||
},
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"send_text",
|
||||
handle_send_text,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_TEXT): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
|
||||
def _get_coordinator(
|
||||
hass: HomeAssistant, device_id: str
|
||||
) -> SystemBridgeDataUpdateCoordinator:
|
||||
"""Return the coordinator for a device id."""
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device_id},
|
||||
)
|
||||
try:
|
||||
entry_id = next(
|
||||
entry.entry_id
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.entry_id in device_entry.config_entries
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device_id},
|
||||
) from e
|
||||
entry: SystemBridgeConfigEntry = service.async_get_config_entry(
|
||||
hass, DOMAIN, entry_id
|
||||
)
|
||||
return entry.runtime_data
|
||||
|
||||
|
||||
async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the get process by id service call."""
|
||||
_LOGGER.debug("Get process by id: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
processes: list[Process] = coordinator.data.processes
|
||||
|
||||
# Find process.id from list, raise ServiceValidationError if not found
|
||||
try:
|
||||
return asdict(
|
||||
next(
|
||||
process
|
||||
for process in processes
|
||||
if process.id == service_call.data[CONF_ID]
|
||||
)
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="process_not_found",
|
||||
translation_placeholders={"id": service_call.data[CONF_ID]},
|
||||
) from e
|
||||
|
||||
|
||||
async def handle_get_processes_by_name(
|
||||
service_call: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handle the get process by name service call."""
|
||||
_LOGGER.debug("Get process by name: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
|
||||
# Find processes from list
|
||||
items: list[dict[str, Any]] = [
|
||||
asdict(process)
|
||||
for process in coordinator.data.processes
|
||||
if process.name is not None
|
||||
and service_call.data[CONF_NAME].lower() in process.name.lower()
|
||||
]
|
||||
|
||||
return {
|
||||
"count": len(items),
|
||||
"processes": list(items),
|
||||
}
|
||||
|
||||
|
||||
async def handle_open_path(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open path service call."""
|
||||
_LOGGER.debug("Open path: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_path(
|
||||
OpenPath(path=service_call.data[CONF_PATH])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_power_command(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the power command service call."""
|
||||
_LOGGER.debug("Power command: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await getattr(
|
||||
coordinator.websocket_client,
|
||||
POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]],
|
||||
)()
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_open_url(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open url service call."""
|
||||
_LOGGER.debug("Open URL: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_url(
|
||||
OpenUrl(url=service_call.data[CONF_URL])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_keypress service call."""
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_keypress(
|
||||
KeyboardKey(key=service_call.data[CONF_KEY])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_send_text(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_text service call."""
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_text(
|
||||
KeyboardText(text=service_call.data[CONF_TEXT])
|
||||
)
|
||||
return asdict(response)
|
||||
Generated
+1
@@ -384,6 +384,7 @@ FLOWS = {
|
||||
"knocki",
|
||||
"knx",
|
||||
"kodi",
|
||||
"konnected",
|
||||
"kostal_plenticore",
|
||||
"kraken",
|
||||
"kulersky",
|
||||
|
||||
@@ -3574,6 +3574,12 @@
|
||||
"konnected": {
|
||||
"name": "Konnected",
|
||||
"integrations": {
|
||||
"konnected": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
"name": "Konnected.io (Legacy)"
|
||||
},
|
||||
"konnected_esphome": {
|
||||
"integration_type": "virtual",
|
||||
"config_flow": false,
|
||||
|
||||
Generated
+5
@@ -201,6 +201,11 @@ SSDP = {
|
||||
"manufacturer": "ZyXEL Communications Corp.",
|
||||
},
|
||||
],
|
||||
"konnected": [
|
||||
{
|
||||
"manufacturer": "konnected.io",
|
||||
},
|
||||
],
|
||||
"lametric": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:LaMetric:1",
|
||||
|
||||
Generated
+4
-1
@@ -1422,6 +1422,9 @@ knocki==0.4.2
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2026.4.30.60856
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
|
||||
# homeassistant.components.kraken
|
||||
krakenex==2.2.2
|
||||
|
||||
@@ -1513,7 +1516,7 @@ lxml==6.0.1
|
||||
matrix-nio==0.25.2
|
||||
|
||||
# homeassistant.components.matter
|
||||
matter-python-client==0.6.0
|
||||
matter-python-client==0.7.1
|
||||
|
||||
# homeassistant.components.maxcube
|
||||
maxcube-api==0.4.3
|
||||
|
||||
+402
-62
@@ -2,9 +2,14 @@
|
||||
"""Helper script to split test into n buckets."""
|
||||
|
||||
import argparse
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field, replace
|
||||
import hashlib
|
||||
import json
|
||||
from math import ceil
|
||||
from operator import attrgetter, itemgetter
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
@@ -15,13 +20,21 @@ from typing import Final
|
||||
# place to subdivide to keep each pytest invocation roughly equal in size.
|
||||
_FAN_OUT_DIRS: Final = frozenset({"components"})
|
||||
|
||||
# Cache file format version; bump on any incompatible schema change so old
|
||||
# caches are ignored rather than misread.
|
||||
_CACHE_VERSION: Final = 3
|
||||
|
||||
# Fall back from file-level to directory-level pytest collection when
|
||||
# misses make up more than this fraction of the tree; past that point
|
||||
# the per-file argv overhead pytest pays outweighs the cost of letting
|
||||
# it re-walk dirs and re-collect the hits.
|
||||
_DIR_LEVEL_MISS_RATIO: Final = 0.3
|
||||
|
||||
|
||||
class Bucket:
|
||||
"""Class to hold bucket."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize bucket."""
|
||||
self.total_tests = 0
|
||||
self._paths: list[str] = []
|
||||
@@ -47,43 +60,56 @@ class BucketHolder:
|
||||
self._buckets: list[Bucket] = [Bucket() for _ in range(bucket_count)]
|
||||
|
||||
def split_tests(self, test_folder: TestFolder) -> None:
|
||||
"""Split tests into buckets."""
|
||||
"""Place atomic units via best-fit; oversized ones go to the smallest bucket."""
|
||||
digits = len(str(test_folder.total_tests))
|
||||
sorted_tests = sorted(
|
||||
test_folder.get_all_flatten(), reverse=True, key=lambda x: x.total_tests
|
||||
)
|
||||
for tests in sorted_tests:
|
||||
if tests.added_to_bucket:
|
||||
# Already added to bucket
|
||||
continue
|
||||
by_load = attrgetter("total_tests")
|
||||
units = sorted(self._atomic_units(test_folder), key=itemgetter(0), reverse=True)
|
||||
for size, items in units:
|
||||
for item in items:
|
||||
tag = " (same bucket)" if item is not items[0] else ""
|
||||
print(f"{item.total_tests:>{digits}} tests in {item.path}{tag}")
|
||||
fits = [
|
||||
b
|
||||
for b in self._buckets
|
||||
if b.total_tests + size <= self._tests_per_bucket
|
||||
]
|
||||
bucket = max(fits, key=by_load) if fits else min(self._buckets, key=by_load)
|
||||
for item in items:
|
||||
bucket.add(item)
|
||||
|
||||
print(f"{tests.total_tests:>{digits}} tests in {tests.path}")
|
||||
smallest_bucket = min(self._buckets, key=lambda x: x.total_tests)
|
||||
is_file = isinstance(tests, TestFile)
|
||||
if (
|
||||
smallest_bucket.total_tests + tests.total_tests < self._tests_per_bucket
|
||||
) or is_file:
|
||||
smallest_bucket.add(tests)
|
||||
# Ensure all files from the same folder are in the same bucket
|
||||
# to ensure that syrupy correctly identifies unused snapshots
|
||||
if is_file:
|
||||
for other_test in tests.parent.children.values():
|
||||
if other_test is tests or isinstance(other_test, TestFolder):
|
||||
continue
|
||||
print(
|
||||
f"{other_test.total_tests:>{digits}}"
|
||||
f" tests in {other_test.path}"
|
||||
" (same bucket)"
|
||||
)
|
||||
smallest_bucket.add(other_test)
|
||||
|
||||
# verify that all tests are added to a bucket
|
||||
if not test_folder.added_to_bucket:
|
||||
raise ValueError("Not all tests are added to a bucket")
|
||||
|
||||
def create_ouput_file(self) -> None:
|
||||
def _atomic_units(
|
||||
self, folder: TestFolder
|
||||
) -> Iterator[tuple[int, list[TestFolder | TestFile]]]:
|
||||
"""Yield ``(size, items)`` placement units.
|
||||
|
||||
A folder that fits is one unit; otherwise same-dir files form
|
||||
a unit only when the folder has syrupy snapshots, else each
|
||||
file stands alone. Sub-folders recurse independently.
|
||||
"""
|
||||
if folder.total_tests <= self._tests_per_bucket:
|
||||
yield folder.total_tests, [folder]
|
||||
return
|
||||
|
||||
sibling_files = [c for c in folder.children.values() if isinstance(c, TestFile)]
|
||||
if sibling_files:
|
||||
if _has_snapshots(folder.path):
|
||||
yield (
|
||||
sum(f.total_tests for f in sibling_files),
|
||||
list(sibling_files),
|
||||
)
|
||||
else:
|
||||
for file in sibling_files:
|
||||
yield file.total_tests, [file]
|
||||
for child in folder.children.values():
|
||||
if isinstance(child, TestFolder):
|
||||
yield from self._atomic_units(child)
|
||||
|
||||
def create_output_file(self) -> None:
|
||||
"""Create output file."""
|
||||
with Path("pytest_buckets.txt").open("w") as file:
|
||||
with Path("pytest_buckets.txt").open("w", encoding="utf-8") as file:
|
||||
for idx, bucket in enumerate(self._buckets):
|
||||
print(f"Bucket {idx + 1} has {bucket.total_tests} tests")
|
||||
file.write(bucket.get_paths_line())
|
||||
@@ -170,6 +196,15 @@ class TestFolder:
|
||||
return result
|
||||
|
||||
|
||||
def _has_snapshots(folder_path: Path) -> bool:
|
||||
"""Return True when ``folder_path/snapshots`` holds ``.ambr`` files.
|
||||
|
||||
Same-dir tests must share a pytest run so syrupy can spot unused
|
||||
snapshots; without snapshots that constraint doesn't apply.
|
||||
"""
|
||||
return any((folder_path / "snapshots").glob("*.ambr"))
|
||||
|
||||
|
||||
def _collect_batch(paths: list[Path]) -> tuple[str, str, int]:
|
||||
"""Run pytest --collect-only on a batch of paths."""
|
||||
result = subprocess.run(
|
||||
@@ -216,44 +251,343 @@ def _enumerate_batch_paths(path: Path) -> list[Path]:
|
||||
return paths
|
||||
|
||||
|
||||
def collect_tests(path: Path) -> TestFolder:
|
||||
"""Collect all tests."""
|
||||
batch_paths = _enumerate_batch_paths(path)
|
||||
if not batch_paths:
|
||||
print(f"No eligible test paths found under {path}")
|
||||
sys.exit(1)
|
||||
workers = min(len(batch_paths), os.cpu_count() or 1) or 1
|
||||
# Round-robin chunking keeps batches roughly balanced when path
|
||||
# ordering correlates with test size.
|
||||
batches = [batch_paths[i::workers] for i in range(workers)]
|
||||
def _hash_file(path: Path) -> str:
|
||||
"""Return a short content hash for ``path``."""
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _walk_test_tree(root: Path) -> tuple[list[Path], list[Path]]:
|
||||
"""Walk ``root`` once and return (test files, fixture files).
|
||||
|
||||
Fixtures are every non-``test_*.py`` ``.py``: conftests and helpers
|
||||
like ``common.py`` that drive parametrize imports. Uses ``os.walk``
|
||||
(~2x faster than ``Path.rglob`` on this tree) and prunes ``.``/``_``
|
||||
subdirs.
|
||||
"""
|
||||
test_files: list[Path] = []
|
||||
fixtures: list[Path] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith((".", "_"))]
|
||||
base = Path(dirpath)
|
||||
for name in filenames:
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
if name.startswith("test_"):
|
||||
test_files.append(base / name)
|
||||
else:
|
||||
fixtures.append(base / name)
|
||||
test_files.sort()
|
||||
fixtures.sort()
|
||||
return test_files, fixtures
|
||||
|
||||
|
||||
_PROJECT_ROOT_MARKERS: Final = frozenset(
|
||||
{"pyproject.toml", "setup.py", "setup.cfg", "pytest.ini", "tox.ini"}
|
||||
)
|
||||
|
||||
|
||||
def _find_ancestor_fixtures(root: Path) -> list[Path]:
|
||||
"""Return non-``test_*.py`` Python files above ``root``, up to the project root.
|
||||
|
||||
Includes conftests and helper modules (eg ``common.py``); subtree
|
||||
runs need both so shared ancestor helpers like
|
||||
``tests/components/common.py`` still invalidate descendants.
|
||||
Stops at the first ancestor containing a project-root marker so we
|
||||
don't read unrelated ``.py`` files outside the repo or trip on
|
||||
dirs we can't list.
|
||||
"""
|
||||
fixtures: list[Path] = []
|
||||
current = root.resolve().parent
|
||||
while True:
|
||||
with suppress(OSError):
|
||||
fixtures.extend(
|
||||
entry
|
||||
for entry in current.glob("*.py")
|
||||
if not entry.name.startswith("test_")
|
||||
)
|
||||
if any((current / marker).exists() for marker in _PROJECT_ROOT_MARKERS):
|
||||
break
|
||||
if current == current.parent:
|
||||
break
|
||||
current = current.parent
|
||||
return fixtures
|
||||
|
||||
|
||||
def _build_fixtures_by_dir(
|
||||
root: Path, descendants: list[Path]
|
||||
) -> dict[Path, list[Path]]:
|
||||
"""Bucket descendants plus ancestor fixtures by resolved parent dir."""
|
||||
by_dir: dict[Path, list[Path]] = {}
|
||||
for fixture in (*_find_ancestor_fixtures(root), *descendants):
|
||||
by_dir.setdefault(fixture.parent.resolve(), []).append(fixture)
|
||||
return by_dir
|
||||
|
||||
|
||||
def _file_fixture_hash(
|
||||
test_file: Path,
|
||||
root: Path,
|
||||
fixtures_by_dir: dict[Path, list[Path]],
|
||||
blob_cache: dict[Path, bytes] | None = None,
|
||||
dir_cache: dict[Path, str] | None = None,
|
||||
) -> str:
|
||||
"""Hash every ``.py`` fixture on the test file's ancestor path.
|
||||
|
||||
Catches conftests and helper modules (``common.py`` etc.) at any
|
||||
level so parametrize imports from shared helpers invalidate
|
||||
descendants, while sibling subtrees stay warm. Pass shared
|
||||
``blob_cache``/``dir_cache`` dicts to memoize across many files.
|
||||
"""
|
||||
test_dir = test_file.parent.resolve()
|
||||
if dir_cache is not None and (cached := dir_cache.get(test_dir)) is not None:
|
||||
return cached
|
||||
relevant: list[Path] = []
|
||||
current = test_dir
|
||||
while True:
|
||||
relevant.extend(fixtures_by_dir.get(current, ()))
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
relevant.sort()
|
||||
digest = hashlib.sha256()
|
||||
for fixture in relevant:
|
||||
blob = blob_cache.get(fixture) if blob_cache is not None else None
|
||||
if blob is None:
|
||||
# relpath keeps the hash machine-stable across ancestor paths.
|
||||
blob = (
|
||||
os.path.relpath(fixture, root).encode()
|
||||
+ b"\0"
|
||||
+ fixture.read_bytes()
|
||||
+ b"\0"
|
||||
)
|
||||
if blob_cache is not None:
|
||||
blob_cache[fixture] = blob
|
||||
digest.update(blob)
|
||||
result = digest.hexdigest()
|
||||
if dir_cache is not None:
|
||||
dir_cache[test_dir] = result
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
"""Cached test count plus its scope hash for a single file."""
|
||||
|
||||
hash: str
|
||||
fixture_hash: str
|
||||
count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Cache:
|
||||
"""Mapping of test file path → cached entry."""
|
||||
|
||||
entries: dict[str, _CacheEntry]
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> _Cache:
|
||||
"""Load cache; any drift (missing, bad, version, malformed) returns empty."""
|
||||
try:
|
||||
raw = json.loads(path.read_bytes())
|
||||
except OSError, ValueError:
|
||||
raw = None
|
||||
if not (
|
||||
isinstance(raw, dict)
|
||||
and raw.get("version") == _CACHE_VERSION
|
||||
and isinstance(raw.get("files"), dict)
|
||||
):
|
||||
return cls(entries={})
|
||||
entries: dict[str, _CacheEntry] = {}
|
||||
for key, value in raw["files"].items():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
hash_value = value.get("hash")
|
||||
fixture_hash = value.get("fixture_hash")
|
||||
count = value.get("count")
|
||||
# bool is an int subclass; reject true/false and negatives so
|
||||
# corrupted JSON can't feed bucket sizing a bogus weight.
|
||||
if (
|
||||
not isinstance(hash_value, str)
|
||||
or not isinstance(fixture_hash, str)
|
||||
or not isinstance(count, int)
|
||||
or isinstance(count, bool)
|
||||
or count < 0
|
||||
):
|
||||
continue
|
||||
entries[key] = _CacheEntry(
|
||||
hash=hash_value, fixture_hash=fixture_hash, count=count
|
||||
)
|
||||
return cls(entries=entries)
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
"""Write the cache to ``path``, creating parent dirs as needed."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": _CACHE_VERSION,
|
||||
"files": {
|
||||
key: {
|
||||
"hash": entry.hash,
|
||||
"fixture_hash": entry.fixture_hash,
|
||||
"count": entry.count,
|
||||
}
|
||||
for key, entry in sorted(self.entries.items())
|
||||
},
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_entries(
|
||||
test_files: list[Path],
|
||||
cache: _Cache,
|
||||
root: Path,
|
||||
fixtures_by_dir: dict[Path, list[Path]],
|
||||
) -> tuple[dict[Path, _CacheEntry], list[Path]]:
|
||||
"""Build an entry for every file; return ``(entries, misses)``.
|
||||
|
||||
Hits reuse the stored entry; misses get fresh hashes with a
|
||||
count=0 placeholder for the caller to fill in after pytest runs.
|
||||
Shared caches memoize fixture blobs and per-dir hashes so each
|
||||
fixture file is read once and each unique dir hashed once.
|
||||
"""
|
||||
blob_cache: dict[Path, bytes] = {}
|
||||
dir_cache: dict[Path, str] = {}
|
||||
entries: dict[Path, _CacheEntry] = {}
|
||||
misses: list[Path] = []
|
||||
for file in test_files:
|
||||
file_hash = _hash_file(file)
|
||||
fixture_hash = _file_fixture_hash(
|
||||
file, root, fixtures_by_dir, blob_cache, dir_cache
|
||||
)
|
||||
cached = cache.entries.get(str(file.relative_to(root)))
|
||||
if (
|
||||
cached is not None
|
||||
and cached.hash == file_hash
|
||||
and cached.fixture_hash == fixture_hash
|
||||
):
|
||||
entries[file] = cached
|
||||
else:
|
||||
entries[file] = _CacheEntry(
|
||||
hash=file_hash, fixture_hash=fixture_hash, count=0
|
||||
)
|
||||
misses.append(file)
|
||||
return entries, misses
|
||||
|
||||
|
||||
def _run_collect_batches(paths: list[Path]) -> list[tuple[str, str, int]]:
|
||||
"""Run pytest --collect-only across ``paths`` using a process pool."""
|
||||
workers = min(len(paths), os.cpu_count() or 1) or 1
|
||||
batches = [paths[i::workers] for i in range(workers)]
|
||||
if workers == 1:
|
||||
results = [_collect_batch(batches[0])]
|
||||
else:
|
||||
with ProcessPoolExecutor(max_workers=workers) as executor:
|
||||
results = list(executor.map(_collect_batch, batches))
|
||||
return [_collect_batch(batches[0])]
|
||||
with ProcessPoolExecutor(max_workers=workers) as executor:
|
||||
return list(executor.map(_collect_batch, batches))
|
||||
|
||||
folder = TestFolder(path)
|
||||
for stdout, stderr, returncode in results:
|
||||
|
||||
def _parse_collect_output(stdout: str) -> dict[Path, int]:
|
||||
"""Parse ``pytest --collect-only -qq`` output into ``{path: count}``."""
|
||||
counts: dict[Path, int] = {}
|
||||
for line in stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
file_path, _, total_tests = line.partition(": ")
|
||||
if not file_path or not total_tests:
|
||||
raise ValueError(f"Unexpected line: {line}")
|
||||
counts[Path(file_path)] = int(total_tests)
|
||||
return counts
|
||||
|
||||
|
||||
def _run_pytest_collect(paths: list[Path]) -> dict[Path, int]:
|
||||
"""Run pytest --collect-only across ``paths`` and parse the output."""
|
||||
counts: dict[Path, int] = {}
|
||||
for stdout, stderr, returncode in _run_collect_batches(paths):
|
||||
if returncode != 0:
|
||||
print("Failed to collect tests:")
|
||||
print(stderr)
|
||||
print(stdout)
|
||||
sys.exit(1)
|
||||
for line in stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
file_path, _, total_tests = line.partition(": ")
|
||||
if not file_path or not total_tests:
|
||||
print(f"Unexpected line: {line}")
|
||||
sys.exit(1)
|
||||
# Surface stderr from successful runs too; pytest puts deprecation
|
||||
# and import warnings here that would otherwise vanish.
|
||||
if stderr.strip():
|
||||
sys.stderr.write(stderr)
|
||||
try:
|
||||
counts.update(_parse_collect_output(stdout))
|
||||
except ValueError as err:
|
||||
print(err)
|
||||
sys.exit(1)
|
||||
return counts
|
||||
|
||||
file = TestFile(int(total_tests), Path(file_path))
|
||||
folder.add_test_file(file)
|
||||
|
||||
def _build_folder(root: Path, counts: dict[Path, int]) -> TestFolder:
|
||||
"""Build a ``TestFolder`` from ``{path: count}``; zero-count files are skipped."""
|
||||
folder = TestFolder(root)
|
||||
for file_path, count in counts.items():
|
||||
if count:
|
||||
folder.add_test_file(TestFile(count, file_path))
|
||||
return folder
|
||||
|
||||
|
||||
def _exit_if_empty(paths: list[Path], root: Path) -> None:
|
||||
"""Exit with a clear message when no eligible test paths were found."""
|
||||
if not paths:
|
||||
print(f"No eligible test paths found under {root}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _collect_tests_uncached(path: Path) -> TestFolder:
|
||||
"""Hand pytest the top-level dirs; the pre-cache path when ``--cache`` is unset."""
|
||||
batch_paths = _enumerate_batch_paths(path)
|
||||
_exit_if_empty(batch_paths, path)
|
||||
return _build_folder(path, _run_pytest_collect(batch_paths))
|
||||
|
||||
|
||||
def _collect_tests_cached(path: Path, cache_path: Path) -> TestFolder:
|
||||
"""Collect tests using an on-disk cache for incremental updates."""
|
||||
all_test_files, fixtures = _walk_test_tree(path)
|
||||
_exit_if_empty(all_test_files, path)
|
||||
|
||||
fixtures_by_dir = _build_fixtures_by_dir(path, fixtures)
|
||||
cache = _Cache.load(cache_path)
|
||||
entries, misses = _resolve_entries(all_test_files, cache, path, fixtures_by_dir)
|
||||
hits = len(all_test_files) - len(misses)
|
||||
print(f"Cache: {hits} hits / {len(misses)} misses / {len(all_test_files)} total")
|
||||
|
||||
if misses:
|
||||
# Past _DIR_LEVEL_MISS_RATIO the per-file argv overhead beats
|
||||
# re-walking the dirs, so fall back to dir-level collection.
|
||||
if not hits or len(misses) > len(all_test_files) * _DIR_LEVEL_MISS_RATIO:
|
||||
collect_paths = _enumerate_batch_paths(path)
|
||||
else:
|
||||
collect_paths = misses
|
||||
new_counts = _run_pytest_collect(collect_paths)
|
||||
# Files pytest returned no count for stay at 0; cached so they
|
||||
# aren't re-collected next run.
|
||||
for file in misses:
|
||||
entries[file] = replace(entries[file], count=new_counts.get(file, 0))
|
||||
|
||||
_Cache(entries={str(f.relative_to(path)): e for f, e in entries.items()}).save(
|
||||
cache_path
|
||||
)
|
||||
return _build_folder(path, {f: e.count for f, e in entries.items()})
|
||||
|
||||
|
||||
def collect_tests(path: Path, cache_path: Path | None = None) -> TestFolder:
|
||||
"""Collect all tests, using an on-disk cache when ``cache_path`` is set."""
|
||||
if cache_path is None:
|
||||
return _collect_tests_uncached(path)
|
||||
if path.is_file():
|
||||
# No fixture tree to scope against; bypass cache to avoid stale hits.
|
||||
print(f"--cache ignored: {path} is a single file")
|
||||
return _collect_tests_uncached(path)
|
||||
return _collect_tests_cached(path, cache_path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Execute script."""
|
||||
parser = argparse.ArgumentParser(description="Split tests into n buckets.")
|
||||
@@ -276,11 +610,17 @@ def main() -> None:
|
||||
help="Path to the test files to split into buckets",
|
||||
type=Path,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cache",
|
||||
help="Path to a JSON file used to cache per-file test counts",
|
||||
type=Path,
|
||||
default=None,
|
||||
)
|
||||
|
||||
arguments = parser.parse_args()
|
||||
|
||||
print("Collecting tests...")
|
||||
tests = collect_tests(arguments.path)
|
||||
tests = collect_tests(arguments.path, arguments.cache)
|
||||
tests_per_bucket = ceil(tests.total_tests / arguments.bucket_count)
|
||||
|
||||
bucket_holder = BucketHolder(tests_per_bucket, arguments.bucket_count)
|
||||
@@ -290,7 +630,7 @@ def main() -> None:
|
||||
print(f"Total tests: {tests.total_tests}")
|
||||
print(f"Estimated tests per bucket: {tests_per_bucket}")
|
||||
|
||||
bucket_holder.create_ouput_file()
|
||||
bucket_holder.create_output_file()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN
|
||||
from homeassistant.components.usb import DOMAIN as USB_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -65,3 +66,66 @@ async def test_hardware_info(
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def test_hardware_info_ignored_entry(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info
|
||||
) -> None:
|
||||
"""Test ignored discovery entries don't crash hardware info.
|
||||
|
||||
Regression test for https://github.com/home-assistant/core/issues/170270
|
||||
"""
|
||||
assert await async_setup_component(hass, USB_DOMAIN, {})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
|
||||
# Setup the normal entry so the hardware platform is loaded
|
||||
normal_entry = MockConfigEntry(
|
||||
data=CONFIG_ENTRY_DATA,
|
||||
domain=DOMAIN,
|
||||
options={},
|
||||
title="Home Assistant Connect ZBT-2",
|
||||
unique_id="normal_1",
|
||||
version=1,
|
||||
minor_version=1,
|
||||
)
|
||||
normal_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(normal_entry.entry_id)
|
||||
|
||||
# Setup an ignored config entry without USB data
|
||||
ignored_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=DOMAIN,
|
||||
options={},
|
||||
title="Home Assistant Connect ZBT-2",
|
||||
unique_id="ignored_1",
|
||||
version=1,
|
||||
minor_version=2,
|
||||
source="ignore",
|
||||
)
|
||||
ignored_entry.add_to_hass(hass)
|
||||
assert ignored_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json({"id": 1, "type": "hardware/info"})
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["id"] == 1
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"hardware": [
|
||||
{
|
||||
"board": None,
|
||||
"config_entries": [normal_entry.entry_id],
|
||||
"dongle": {
|
||||
"vid": "303A",
|
||||
"pid": "4001",
|
||||
"serial_number": "80B54EEFAE18",
|
||||
"manufacturer": "Nabu Casa",
|
||||
"description": "ZBT-2",
|
||||
},
|
||||
"name": "Home Assistant Connect ZBT-2",
|
||||
"url": "https://support.nabucasa.com/hc/en-us/categories/24734620813469-Home-Assistant-Connect-ZBT-1",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2363,6 +2363,11 @@ async def test_reload(mock_port_available: MagicMock, hass: HomeAssistant) -> No
|
||||
devices=[],
|
||||
)
|
||||
|
||||
# Unload while async_port_is_available is still patched so the hass fixture
|
||||
# teardown does not block on the real port check loop in async_unload_entry.
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_async_zeroconf")
|
||||
async def test_homekit_start_in_accessory_mode(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +1,950 @@
|
||||
"""Tests for the Konnected.io component."""
|
||||
"""Test Konnected setup process."""
|
||||
|
||||
from homeassistant.components.konnected import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import konnected
|
||||
from homeassistant.components.konnected import config_flow
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.core_config import async_process_ha_core_config
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
async def test_konnected_repair_issue(
|
||||
hass: HomeAssistant, issue_registry: ir.IssueRegistry
|
||||
) -> None:
|
||||
"""Test the Konnected.io configuration entry loading/unloading handles the repair."""
|
||||
config_entry_1 = MockConfigEntry(
|
||||
title="Example 1",
|
||||
domain=DOMAIN,
|
||||
@pytest.fixture(name="mock_panel")
|
||||
async def mock_panel_fixture():
|
||||
"""Mock a Konnected Panel bridge."""
|
||||
with patch("konnected.Client", autospec=True) as konn_client:
|
||||
|
||||
def mock_constructor(host, port, websession):
|
||||
"""Fake the panel constructor."""
|
||||
konn_client.host = host
|
||||
konn_client.port = port
|
||||
return konn_client
|
||||
|
||||
konn_client.side_effect = mock_constructor
|
||||
konn_client.ClientError = config_flow.CannotConnect
|
||||
konn_client.get_status.return_value = {
|
||||
"hwVersion": "2.3.0",
|
||||
"swVersion": "2.3.1",
|
||||
"heap": 10000,
|
||||
"uptime": 12222,
|
||||
"ip": "192.168.1.90",
|
||||
"port": 9123,
|
||||
"sensors": [],
|
||||
"actuators": [],
|
||||
"dht_sensors": [],
|
||||
"ds18b20_sensors": [],
|
||||
"mac": "11:22:33:44:55:66",
|
||||
"settings": {},
|
||||
}
|
||||
yield konn_client
|
||||
|
||||
|
||||
async def test_config_schema(hass: HomeAssistant) -> None:
|
||||
"""Test that config schema is imported properly."""
|
||||
config = {
|
||||
konnected.DOMAIN: {
|
||||
konnected.CONF_API_HOST: "http://1.1.1.1:8888",
|
||||
konnected.CONF_ACCESS_TOKEN: "abcdefgh",
|
||||
konnected.CONF_DEVICES: [{konnected.CONF_ID: "aabbccddeeff"}],
|
||||
}
|
||||
}
|
||||
assert konnected.CONFIG_SCHEMA(config) == {
|
||||
"konnected": {
|
||||
"access_token": "abcdefgh",
|
||||
"api_host": "http://1.1.1.1:8888",
|
||||
"devices": [
|
||||
{
|
||||
"default_options": {
|
||||
"blink": True,
|
||||
"api_host": "http://1.1.1.1:8888",
|
||||
"discovery": True,
|
||||
"io": {
|
||||
"1": "Disabled",
|
||||
"10": "Disabled",
|
||||
"11": "Disabled",
|
||||
"12": "Disabled",
|
||||
"2": "Disabled",
|
||||
"3": "Disabled",
|
||||
"4": "Disabled",
|
||||
"5": "Disabled",
|
||||
"6": "Disabled",
|
||||
"7": "Disabled",
|
||||
"8": "Disabled",
|
||||
"9": "Disabled",
|
||||
"alarm1": "Disabled",
|
||||
"alarm2_out2": "Disabled",
|
||||
"out": "Disabled",
|
||||
"out1": "Disabled",
|
||||
},
|
||||
},
|
||||
"id": "aabbccddeeff",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
# check with host info
|
||||
config = {
|
||||
konnected.DOMAIN: {
|
||||
konnected.CONF_ACCESS_TOKEN: "abcdefgh",
|
||||
konnected.CONF_DEVICES: [
|
||||
{konnected.CONF_ID: "aabbccddeeff", "host": "192.168.1.1", "port": 1234}
|
||||
],
|
||||
}
|
||||
}
|
||||
assert konnected.CONFIG_SCHEMA(config) == {
|
||||
"konnected": {
|
||||
"access_token": "abcdefgh",
|
||||
"devices": [
|
||||
{
|
||||
"default_options": {
|
||||
"blink": True,
|
||||
"api_host": "",
|
||||
"discovery": True,
|
||||
"io": {
|
||||
"1": "Disabled",
|
||||
"10": "Disabled",
|
||||
"11": "Disabled",
|
||||
"12": "Disabled",
|
||||
"2": "Disabled",
|
||||
"3": "Disabled",
|
||||
"4": "Disabled",
|
||||
"5": "Disabled",
|
||||
"6": "Disabled",
|
||||
"7": "Disabled",
|
||||
"8": "Disabled",
|
||||
"9": "Disabled",
|
||||
"alarm1": "Disabled",
|
||||
"alarm2_out2": "Disabled",
|
||||
"out": "Disabled",
|
||||
"out1": "Disabled",
|
||||
},
|
||||
},
|
||||
"id": "aabbccddeeff",
|
||||
"host": "192.168.1.1",
|
||||
"port": 1234,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
# check pin to zone and multiple output
|
||||
config = {
|
||||
konnected.DOMAIN: {
|
||||
konnected.CONF_ACCESS_TOKEN: "abcdefgh",
|
||||
konnected.CONF_DEVICES: [
|
||||
{
|
||||
konnected.CONF_ID: "aabbccddeeff",
|
||||
"binary_sensors": [
|
||||
{"pin": 2, "type": "door"},
|
||||
{"zone": 1, "type": "door"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"zone": 3,
|
||||
"name": "Beep Beep",
|
||||
"momentary": 65,
|
||||
"pause": 55,
|
||||
"repeat": 4,
|
||||
},
|
||||
{
|
||||
"zone": 3,
|
||||
"name": "Warning",
|
||||
"momentary": 100,
|
||||
"pause": 100,
|
||||
"repeat": -1,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
assert konnected.CONFIG_SCHEMA(config) == {
|
||||
"konnected": {
|
||||
"access_token": "abcdefgh",
|
||||
"devices": [
|
||||
{
|
||||
"default_options": {
|
||||
"blink": True,
|
||||
"api_host": "",
|
||||
"discovery": True,
|
||||
"io": {
|
||||
"1": "Binary Sensor",
|
||||
"10": "Disabled",
|
||||
"11": "Disabled",
|
||||
"12": "Disabled",
|
||||
"2": "Binary Sensor",
|
||||
"3": "Switchable Output",
|
||||
"4": "Disabled",
|
||||
"5": "Disabled",
|
||||
"6": "Disabled",
|
||||
"7": "Disabled",
|
||||
"8": "Disabled",
|
||||
"9": "Disabled",
|
||||
"alarm1": "Disabled",
|
||||
"alarm2_out2": "Disabled",
|
||||
"out": "Disabled",
|
||||
"out1": "Disabled",
|
||||
},
|
||||
"binary_sensors": [
|
||||
{"inverse": False, "type": "door", "zone": "2"},
|
||||
{"inverse": False, "type": "door", "zone": "1"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"zone": "3",
|
||||
"activation": "high",
|
||||
"name": "Beep Beep",
|
||||
"momentary": 65,
|
||||
"pause": 55,
|
||||
"repeat": 4,
|
||||
},
|
||||
{
|
||||
"zone": "3",
|
||||
"activation": "high",
|
||||
"name": "Warning",
|
||||
"momentary": 100,
|
||||
"pause": 100,
|
||||
"repeat": -1,
|
||||
},
|
||||
],
|
||||
},
|
||||
"id": "aabbccddeeff",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_setup_with_no_config(hass: HomeAssistant) -> None:
|
||||
"""Test that we do not discover anything or try to set up a Konnected panel."""
|
||||
assert await async_setup_component(hass, konnected.DOMAIN, {})
|
||||
|
||||
# No flows started
|
||||
assert len(hass.config_entries.flow.async_progress()) == 0
|
||||
|
||||
# Nothing saved from configuration.yaml
|
||||
assert hass.data[konnected.DOMAIN][konnected.CONF_ACCESS_TOKEN] is None
|
||||
assert hass.data[konnected.DOMAIN][konnected.CONF_API_HOST] is None
|
||||
assert konnected.YAML_CONFIGS not in hass.data[konnected.DOMAIN]
|
||||
|
||||
|
||||
async def test_setup_defined_hosts_known_auth(hass: HomeAssistant, mock_panel) -> None:
|
||||
"""Test we don't initiate a config entry if configured panel is known."""
|
||||
MockConfigEntry(
|
||||
domain="konnected",
|
||||
unique_id="112233445566",
|
||||
data={"host": "0.0.0.0", "id": "112233445566"},
|
||||
).add_to_hass(hass)
|
||||
MockConfigEntry(
|
||||
domain="konnected",
|
||||
unique_id="aabbccddeeff",
|
||||
data={"host": "1.2.3.4", "id": "aabbccddeeff"},
|
||||
).add_to_hass(hass)
|
||||
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
konnected.DOMAIN,
|
||||
{
|
||||
konnected.DOMAIN: {
|
||||
konnected.CONF_ACCESS_TOKEN: "abcdefgh",
|
||||
konnected.CONF_DEVICES: [
|
||||
{
|
||||
config_flow.CONF_ID: "aabbccddeeff",
|
||||
config_flow.CONF_HOST: "0.0.0.0",
|
||||
config_flow.CONF_PORT: 1234,
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
is True
|
||||
)
|
||||
config_entry_1.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry_1.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry_1.state is ConfigEntryState.LOADED
|
||||
|
||||
# Add a second one
|
||||
config_entry_2 = MockConfigEntry(
|
||||
title="Example 2",
|
||||
domain=DOMAIN,
|
||||
assert hass.data[konnected.DOMAIN][konnected.CONF_ACCESS_TOKEN] == "abcdefgh"
|
||||
assert konnected.YAML_CONFIGS not in hass.data[konnected.DOMAIN]
|
||||
|
||||
# Flow aborted
|
||||
assert len(hass.config_entries.flow.async_progress()) == 0
|
||||
|
||||
|
||||
async def test_setup_defined_hosts_no_known_auth(hass: HomeAssistant) -> None:
|
||||
"""Test we initiate config entry if config panel is not known."""
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
konnected.DOMAIN,
|
||||
{
|
||||
konnected.DOMAIN: {
|
||||
konnected.CONF_ACCESS_TOKEN: "abcdefgh",
|
||||
konnected.CONF_DEVICES: [{konnected.CONF_ID: "aabbccddeeff"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
is True
|
||||
)
|
||||
config_entry_2.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry_2.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry_2.state is ConfigEntryState.LOADED
|
||||
assert issue_registry.async_get_issue(DOMAIN, DOMAIN)
|
||||
|
||||
# Remove the first one
|
||||
await hass.config_entries.async_remove(config_entry_1.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry_1.state is ConfigEntryState.NOT_LOADED
|
||||
assert config_entry_2.state is ConfigEntryState.LOADED
|
||||
assert issue_registry.async_get_issue(DOMAIN, DOMAIN)
|
||||
|
||||
# Remove the second one
|
||||
await hass.config_entries.async_remove(config_entry_2.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry_1.state is ConfigEntryState.NOT_LOADED
|
||||
assert config_entry_2.state is ConfigEntryState.NOT_LOADED
|
||||
assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None
|
||||
# Flow started for discovered bridge
|
||||
assert len(hass.config_entries.flow.async_progress()) == 1
|
||||
|
||||
|
||||
async def test_konnected_yaml_repair_issue(
|
||||
hass: HomeAssistant, issue_registry: ir.IssueRegistry
|
||||
async def test_setup_multiple(hass: HomeAssistant) -> None:
|
||||
"""Test we initiate config entry for multiple panels."""
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
konnected.DOMAIN,
|
||||
{
|
||||
konnected.DOMAIN: {
|
||||
konnected.CONF_ACCESS_TOKEN: "arandomstringvalue",
|
||||
konnected.CONF_API_HOST: "http://192.168.86.32:8123",
|
||||
konnected.CONF_DEVICES: [
|
||||
{
|
||||
konnected.CONF_ID: "aabbccddeeff",
|
||||
"binary_sensors": [
|
||||
{"zone": 4, "type": "motion", "name": "Hallway Motion"},
|
||||
{
|
||||
"zone": 5,
|
||||
"type": "window",
|
||||
"name": "Master Bedroom Window",
|
||||
},
|
||||
{
|
||||
"zone": 6,
|
||||
"type": "window",
|
||||
"name": "Downstairs Windows",
|
||||
},
|
||||
],
|
||||
"switches": [{"zone": "out", "name": "siren"}],
|
||||
},
|
||||
{
|
||||
konnected.CONF_ID: "445566778899",
|
||||
"binary_sensors": [
|
||||
{"zone": 1, "type": "motion", "name": "Front"},
|
||||
{"zone": 2, "type": "window", "name": "Back"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"zone": "out",
|
||||
"name": "Buzzer",
|
||||
"momentary": 65,
|
||||
"pause": 55,
|
||||
"repeat": 4,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# Flow started for discovered bridge
|
||||
assert len(hass.config_entries.flow.async_progress()) == 2
|
||||
|
||||
# Globals saved
|
||||
assert (
|
||||
hass.data[konnected.DOMAIN][konnected.CONF_ACCESS_TOKEN] == "arandomstringvalue"
|
||||
)
|
||||
assert (
|
||||
hass.data[konnected.DOMAIN][konnected.CONF_API_HOST]
|
||||
== "http://192.168.86.32:8123"
|
||||
)
|
||||
|
||||
|
||||
async def test_config_passed_to_config_entry(hass: HomeAssistant) -> None:
|
||||
"""Test that configured options for a host are loaded via config entry."""
|
||||
entry = MockConfigEntry(
|
||||
domain=konnected.DOMAIN,
|
||||
data={config_flow.CONF_ID: "aabbccddeeff", config_flow.CONF_HOST: "0.0.0.0"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
with patch.object(konnected, "AlarmPanel", autospec=True) as mock_int:
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
konnected.DOMAIN,
|
||||
{
|
||||
konnected.DOMAIN: {
|
||||
konnected.CONF_ACCESS_TOKEN: "abcdefgh",
|
||||
konnected.CONF_DEVICES: [{konnected.CONF_ID: "aabbccddeeff"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
assert len(mock_int.mock_calls) == 3
|
||||
p_hass, p_entry = mock_int.mock_calls[0][1]
|
||||
|
||||
assert p_hass is hass
|
||||
assert p_entry is entry
|
||||
|
||||
|
||||
async def test_unload_entry(hass: HomeAssistant, mock_panel) -> None:
|
||||
"""Test being able to unload an entry."""
|
||||
await async_process_ha_core_config(
|
||||
hass,
|
||||
{"internal_url": "http://example.local:8123"},
|
||||
)
|
||||
entry = MockConfigEntry(
|
||||
domain=konnected.DOMAIN, data={konnected.CONF_ID: "aabbccddeeff"}
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await async_setup_component(hass, konnected.DOMAIN, {}) is True
|
||||
assert hass.data[konnected.DOMAIN]["devices"].get("aabbccddeeff") is not None
|
||||
assert await konnected.async_unload_entry(hass, entry)
|
||||
assert hass.data[konnected.DOMAIN]["devices"] == {}
|
||||
|
||||
|
||||
async def test_api(
|
||||
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, mock_panel
|
||||
) -> None:
|
||||
"""Test the repair issue is created when YAML configuration is present."""
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
"""Test callback view."""
|
||||
await async_setup_component(hass, "http", {"http": {}})
|
||||
|
||||
assert issue_registry.async_get_issue(DOMAIN, DOMAIN)
|
||||
device_config = config_flow.CONFIG_ENTRY_SCHEMA(
|
||||
{
|
||||
"host": "1.2.3.4",
|
||||
"port": 1234,
|
||||
"id": "112233445566",
|
||||
"model": "Konnected Pro",
|
||||
"access_token": "abcdefgh",
|
||||
"api_host": "http://192.168.86.32:8123",
|
||||
"default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
|
||||
}
|
||||
)
|
||||
|
||||
device_options = config_flow.OPTIONS_SCHEMA(
|
||||
{
|
||||
"api_host": "http://192.168.86.32:8123",
|
||||
"io": {
|
||||
"1": "Binary Sensor",
|
||||
"2": "Binary Sensor",
|
||||
"3": "Binary Sensor",
|
||||
"4": "Digital Sensor",
|
||||
"5": "Digital Sensor",
|
||||
"6": "Switchable Output",
|
||||
"out": "Switchable Output",
|
||||
},
|
||||
"binary_sensors": [
|
||||
{"zone": "1", "type": "door"},
|
||||
{"zone": "2", "type": "window", "name": "winder", "inverse": True},
|
||||
{"zone": "3", "type": "door"},
|
||||
],
|
||||
"sensors": [
|
||||
{"zone": "4", "type": "dht"},
|
||||
{"zone": "5", "type": "ds18b20", "name": "temper"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"zone": "out",
|
||||
"name": "switcher",
|
||||
"activation": "low",
|
||||
"momentary": 50,
|
||||
"pause": 100,
|
||||
"repeat": 4,
|
||||
},
|
||||
{"zone": "6"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain="konnected",
|
||||
title="Konnected Alarm Panel",
|
||||
data=device_config,
|
||||
options=device_options,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
konnected.DOMAIN,
|
||||
{konnected.DOMAIN: {konnected.CONF_ACCESS_TOKEN: "globaltoken"}},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
|
||||
# Test the get endpoint for switch status polling
|
||||
resp = await client.get("/api/konnected")
|
||||
assert resp.status == HTTPStatus.NOT_FOUND # no device provided
|
||||
|
||||
resp = await client.get("/api/konnected/223344556677")
|
||||
assert resp.status == HTTPStatus.NOT_FOUND # unknown device provided
|
||||
|
||||
resp = await client.get("/api/konnected/device/112233445566")
|
||||
assert resp.status == HTTPStatus.NOT_FOUND # no zone provided
|
||||
result = await resp.json()
|
||||
assert result == {"message": "Switch on zone or pin unknown not configured"}
|
||||
|
||||
resp = await client.get("/api/konnected/device/112233445566?zone=8")
|
||||
assert resp.status == HTTPStatus.NOT_FOUND # invalid zone
|
||||
result = await resp.json()
|
||||
assert result == {"message": "Switch on zone or pin 8 not configured"}
|
||||
|
||||
resp = await client.get("/api/konnected/device/112233445566?pin=12")
|
||||
assert resp.status == HTTPStatus.NOT_FOUND # invalid pin
|
||||
result = await resp.json()
|
||||
assert result == {"message": "Switch on zone or pin 12 not configured"}
|
||||
|
||||
resp = await client.get("/api/konnected/device/112233445566?zone=out")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"state": 1, "zone": "out"}
|
||||
|
||||
resp = await client.get("/api/konnected/device/112233445566?pin=8")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"state": 1, "pin": "8"}
|
||||
|
||||
# Test the post endpoint for sensor updates
|
||||
resp = await client.post("/api/konnected/device", json={"zone": "1", "state": 1})
|
||||
assert resp.status == HTTPStatus.NOT_FOUND
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566", json={"zone": "1", "state": 1}
|
||||
)
|
||||
assert resp.status == HTTPStatus.UNAUTHORIZED
|
||||
result = await resp.json()
|
||||
assert result == {"message": "unauthorized"}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/223344556677",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"zone": "1", "state": 1},
|
||||
)
|
||||
assert resp.status == HTTPStatus.BAD_REQUEST
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"zone": "15", "state": 1},
|
||||
)
|
||||
assert resp.status == HTTPStatus.BAD_REQUEST
|
||||
result = await resp.json()
|
||||
assert result == {"message": "unregistered sensor/actuator"}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"zone": "1", "state": 1},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer globaltoken"},
|
||||
json={"zone": "1", "state": 1},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"zone": "4", "temp": 22, "humi": 20},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
|
||||
# Test the put endpoint for sensor updates
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"zone": "1", "state": 1},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
|
||||
|
||||
async def test_state_updates_zone(
|
||||
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, mock_panel
|
||||
) -> None:
|
||||
"""Test callback view."""
|
||||
await async_process_ha_core_config(
|
||||
hass,
|
||||
{"internal_url": "http://example.local:8123"},
|
||||
)
|
||||
|
||||
device_config = config_flow.CONFIG_ENTRY_SCHEMA(
|
||||
{
|
||||
"host": "1.2.3.4",
|
||||
"port": 1234,
|
||||
"id": "112233445566",
|
||||
"model": "Konnected Pro",
|
||||
"access_token": "abcdefgh",
|
||||
"default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
|
||||
}
|
||||
)
|
||||
|
||||
device_options = config_flow.OPTIONS_SCHEMA(
|
||||
{
|
||||
"io": {
|
||||
"1": "Binary Sensor",
|
||||
"2": "Binary Sensor",
|
||||
"3": "Binary Sensor",
|
||||
"4": "Digital Sensor",
|
||||
"5": "Digital Sensor",
|
||||
"6": "Switchable Output",
|
||||
"out": "Switchable Output",
|
||||
},
|
||||
"binary_sensors": [
|
||||
{"zone": "1", "type": "door"},
|
||||
{"zone": "2", "type": "window", "name": "winder", "inverse": True},
|
||||
{"zone": "3", "type": "door"},
|
||||
],
|
||||
"sensors": [
|
||||
{"zone": "4", "type": "dht"},
|
||||
{"zone": "5", "type": "ds18b20", "name": "temper"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"zone": "out",
|
||||
"name": "switcher",
|
||||
"activation": "low",
|
||||
"momentary": 50,
|
||||
"pause": 100,
|
||||
"repeat": 4,
|
||||
},
|
||||
{"zone": "6"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain="konnected",
|
||||
title="Konnected Alarm Panel",
|
||||
data=device_config,
|
||||
options=device_options,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# Add empty data field to ensure we process it correctly
|
||||
# (possible if entry is ignored)
|
||||
entry = MockConfigEntry(domain="konnected", title="Konnected Alarm Panel", data={})
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
konnected.DOMAIN,
|
||||
{konnected.DOMAIN: {konnected.CONF_ACCESS_TOKEN: "1122334455"}},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
|
||||
# Test updating a binary sensor
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"zone": "1", "state": 0},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get(
|
||||
"binary_sensor.konnected_alarm_panel_konnected_445566_zone_1"
|
||||
).state
|
||||
== "off"
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"zone": "1", "state": 1},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get(
|
||||
"binary_sensor.konnected_alarm_panel_konnected_445566_zone_1"
|
||||
).state
|
||||
== "on"
|
||||
)
|
||||
|
||||
# Test updating sht sensor
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"zone": "4", "temp": 22, "humi": 20},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get(
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_humidity"
|
||||
).state
|
||||
== "20"
|
||||
)
|
||||
assert (
|
||||
hass.states.get(
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_temperature"
|
||||
).state
|
||||
== "22.0"
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"zone": "4", "temp": 25, "humi": 23},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get(
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_humidity"
|
||||
).state
|
||||
== "23"
|
||||
)
|
||||
assert (
|
||||
hass.states.get(
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_temperature"
|
||||
).state
|
||||
== "25.0"
|
||||
)
|
||||
|
||||
# Test updating ds sensor
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"zone": "5", "temp": 32.0, "addr": 1},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get("sensor.konnected_alarm_panel_temper_temperature").state
|
||||
== "32.0"
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"zone": "5", "temp": 42, "addr": 1},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get("sensor.konnected_alarm_panel_temper_temperature").state
|
||||
== "42.0"
|
||||
)
|
||||
|
||||
|
||||
async def test_state_updates_pin(
|
||||
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, mock_panel
|
||||
) -> None:
|
||||
"""Test callback view."""
|
||||
await async_process_ha_core_config(
|
||||
hass,
|
||||
{"internal_url": "http://example.local:8123"},
|
||||
)
|
||||
|
||||
device_config = config_flow.CONFIG_ENTRY_SCHEMA(
|
||||
{
|
||||
"host": "1.2.3.4",
|
||||
"port": 1234,
|
||||
"id": "112233445566",
|
||||
"model": "Konnected",
|
||||
"access_token": "abcdefgh",
|
||||
"default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
|
||||
}
|
||||
)
|
||||
|
||||
device_options = config_flow.OPTIONS_SCHEMA(
|
||||
{
|
||||
"io": {
|
||||
"1": "Binary Sensor",
|
||||
"2": "Binary Sensor",
|
||||
"3": "Binary Sensor",
|
||||
"4": "Digital Sensor",
|
||||
"5": "Digital Sensor",
|
||||
"6": "Switchable Output",
|
||||
"out": "Switchable Output",
|
||||
},
|
||||
"binary_sensors": [
|
||||
{"zone": "1", "type": "door"},
|
||||
{"zone": "2", "type": "window", "name": "winder", "inverse": True},
|
||||
{"zone": "3", "type": "door"},
|
||||
],
|
||||
"sensors": [
|
||||
{"zone": "4", "type": "dht"},
|
||||
{"zone": "5", "type": "ds18b20", "name": "temper"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"zone": "out",
|
||||
"name": "switcher",
|
||||
"activation": "low",
|
||||
"momentary": 50,
|
||||
"pause": 100,
|
||||
"repeat": 4,
|
||||
},
|
||||
{"zone": "6"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain="konnected",
|
||||
title="Konnected Alarm Panel",
|
||||
data=device_config,
|
||||
options=device_options,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# Add empty data field to ensure we process it correctly
|
||||
# (possible if entry is ignored)
|
||||
entry = MockConfigEntry(
|
||||
domain="konnected",
|
||||
title="Konnected Alarm Panel",
|
||||
data={},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
konnected.DOMAIN,
|
||||
{konnected.DOMAIN: {konnected.CONF_ACCESS_TOKEN: "1122334455"}},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
|
||||
# Test updating a binary sensor
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"pin": "1", "state": 0},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get(
|
||||
"binary_sensor.konnected_alarm_panel_konnected_445566_zone_1"
|
||||
).state
|
||||
== "off"
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"pin": "1", "state": 1},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get(
|
||||
"binary_sensor.konnected_alarm_panel_konnected_445566_zone_1"
|
||||
).state
|
||||
== "on"
|
||||
)
|
||||
|
||||
# Test updating sht sensor
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"pin": "6", "temp": 22, "humi": 20},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get(
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_humidity"
|
||||
).state
|
||||
== "20"
|
||||
)
|
||||
assert (
|
||||
hass.states.get(
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_temperature"
|
||||
).state
|
||||
== "22.0"
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"pin": "6", "temp": 25, "humi": 23},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get(
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_humidity"
|
||||
).state
|
||||
== "23"
|
||||
)
|
||||
assert (
|
||||
hass.states.get(
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_temperature"
|
||||
).state
|
||||
== "25.0"
|
||||
)
|
||||
|
||||
# Test updating ds sensor
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"pin": "7", "temp": 32.0, "addr": 1},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get("sensor.konnected_alarm_panel_temper_temperature").state
|
||||
== "32.0"
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/konnected/device/112233445566",
|
||||
headers={"Authorization": "Bearer abcdefgh"},
|
||||
json={"pin": "7", "temp": 42, "addr": 1},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
result = await resp.json()
|
||||
assert result == {"message": "ok"}
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get("sensor.konnected_alarm_panel_temper_temperature").state
|
||||
== "42.0"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
"""Test Konnected setup process."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.konnected import config_flow, panel
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_component import async_update_entity
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import utcnow
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_panel")
|
||||
async def mock_panel_fixture():
|
||||
"""Mock a Konnected Panel bridge."""
|
||||
with patch("konnected.Client", autospec=True) as konn_client:
|
||||
|
||||
def mock_constructor(host, port, websession):
|
||||
"""Fake the panel constructor."""
|
||||
konn_client.host = host
|
||||
konn_client.port = port
|
||||
return konn_client
|
||||
|
||||
konn_client.side_effect = mock_constructor
|
||||
konn_client.ClientError = config_flow.CannotConnect
|
||||
konn_client.get_status.return_value = {
|
||||
"hwVersion": "2.3.0",
|
||||
"swVersion": "2.3.1",
|
||||
"heap": 10000,
|
||||
"uptime": 12222,
|
||||
"ip": "192.168.1.90",
|
||||
"port": 9123,
|
||||
"sensors": [],
|
||||
"actuators": [],
|
||||
"dht_sensors": [],
|
||||
"ds18b20_sensors": [],
|
||||
"mac": "11:22:33:44:55:66",
|
||||
"model": "Konnected Pro", # `model` field only included in pro
|
||||
"settings": {},
|
||||
}
|
||||
yield konn_client
|
||||
|
||||
|
||||
async def test_create_and_setup(hass: HomeAssistant, mock_panel) -> None:
|
||||
"""Test that we create a Konnected Panel and save the data."""
|
||||
device_config = config_flow.CONFIG_ENTRY_SCHEMA(
|
||||
{
|
||||
"host": "1.2.3.4",
|
||||
"port": 1234,
|
||||
"id": "112233445566",
|
||||
"model": "Konnected Pro",
|
||||
"access_token": "11223344556677889900",
|
||||
"default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
|
||||
}
|
||||
)
|
||||
|
||||
device_options = config_flow.OPTIONS_SCHEMA(
|
||||
{
|
||||
"io": {
|
||||
"1": "Binary Sensor",
|
||||
"2": "Binary Sensor",
|
||||
"3": "Binary Sensor",
|
||||
"4": "Digital Sensor",
|
||||
"5": "Digital Sensor",
|
||||
"6": "Switchable Output",
|
||||
"out": "Switchable Output",
|
||||
},
|
||||
"binary_sensors": [
|
||||
{"zone": "1", "type": "door"},
|
||||
{"zone": "2", "type": "window", "name": "winder", "inverse": True},
|
||||
{"zone": "3", "type": "door"},
|
||||
],
|
||||
"sensors": [
|
||||
{"zone": "4", "type": "dht"},
|
||||
{"zone": "5", "type": "ds18b20", "name": "temper"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"zone": "out",
|
||||
"name": "switcher",
|
||||
"activation": "low",
|
||||
"momentary": 50,
|
||||
"pause": 100,
|
||||
"repeat": 4,
|
||||
},
|
||||
{"zone": "6"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain="konnected",
|
||||
title="Konnected Alarm Panel",
|
||||
data=device_config,
|
||||
options=device_options,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# override get_status to reflect non-pro board
|
||||
mock_panel.get_status.return_value = {
|
||||
"hwVersion": "2.3.0",
|
||||
"swVersion": "2.3.1",
|
||||
"heap": 10000,
|
||||
"uptime": 12222,
|
||||
"ip": "192.168.1.90",
|
||||
"port": 9123,
|
||||
"sensors": [],
|
||||
"actuators": [],
|
||||
"dht_sensors": [],
|
||||
"ds18b20_sensors": [],
|
||||
"mac": "11:22:33:44:55:66",
|
||||
"settings": {},
|
||||
}
|
||||
|
||||
# setup the integration and inspect panel behavior
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
panel.DOMAIN,
|
||||
{
|
||||
panel.DOMAIN: {
|
||||
panel.CONF_ACCESS_TOKEN: "arandomstringvalue",
|
||||
panel.CONF_API_HOST: "http://192.168.1.1:8123",
|
||||
}
|
||||
},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# confirm panel instance was created and configured
|
||||
# hass.data is the only mechanism to get a reference to the created panel instance
|
||||
device = hass.data[panel.DOMAIN][panel.CONF_DEVICES]["112233445566"]["panel"]
|
||||
await device.update_switch("1", 0)
|
||||
|
||||
# confirm the correct api is used
|
||||
assert mock_panel.put_device.call_count == 1
|
||||
assert mock_panel.put_zone.call_count == 0
|
||||
|
||||
# confirm the settings are sent to the panel
|
||||
assert mock_panel.put_settings.call_args_list[0][1] == {
|
||||
"sensors": [{"pin": "1"}, {"pin": "2"}, {"pin": "5"}],
|
||||
"actuators": [{"trigger": 0, "pin": "8"}, {"trigger": 1, "pin": "9"}],
|
||||
"dht_sensors": [{"poll_interval": 3, "pin": "6"}],
|
||||
"ds18b20_sensors": [{"poll_interval": 3, "pin": "7"}],
|
||||
"auth_token": "11223344556677889900",
|
||||
"blink": True,
|
||||
"discovery": True,
|
||||
"endpoint": "http://192.168.1.1:8123/api/konnected",
|
||||
}
|
||||
|
||||
# confirm the device settings are saved in hass.data
|
||||
# This test should not access hass.data since its integration internals
|
||||
assert device.stored_configuration == {
|
||||
"binary_sensors": {
|
||||
"1": {
|
||||
"entity_id": (
|
||||
"binary_sensor.konnected_alarm_panel_konnected_445566_zone_1"
|
||||
),
|
||||
"inverse": False,
|
||||
"name": "Konnected 445566 Zone 1",
|
||||
"state": None,
|
||||
"type": "door",
|
||||
},
|
||||
"2": {
|
||||
"entity_id": "binary_sensor.konnected_alarm_panel_winder",
|
||||
"inverse": True,
|
||||
"name": "winder",
|
||||
"state": None,
|
||||
"type": "window",
|
||||
},
|
||||
"3": {
|
||||
"entity_id": (
|
||||
"binary_sensor.konnected_alarm_panel_konnected_445566_zone_3"
|
||||
),
|
||||
"inverse": False,
|
||||
"name": "Konnected 445566 Zone 3",
|
||||
"state": None,
|
||||
"type": "door",
|
||||
},
|
||||
},
|
||||
"blink": True,
|
||||
"discovery": True,
|
||||
"host": "1.2.3.4",
|
||||
"panel": device,
|
||||
"port": 1234,
|
||||
"sensors": [
|
||||
{
|
||||
"humidity": (
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_humidity"
|
||||
),
|
||||
"name": "Konnected 445566 Sensor 4",
|
||||
"poll_interval": 3,
|
||||
"temperature": (
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_temperature"
|
||||
),
|
||||
"type": "dht",
|
||||
"zone": "4",
|
||||
},
|
||||
{"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "5"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"activation": "low",
|
||||
"entity_id": "switch.konnected_alarm_panel_switcher",
|
||||
"momentary": 50,
|
||||
"name": "switcher",
|
||||
"pause": 100,
|
||||
"repeat": 4,
|
||||
"state": None,
|
||||
"zone": "out",
|
||||
},
|
||||
{
|
||||
"activation": "high",
|
||||
"entity_id": "switch.konnected_alarm_panel_konnected_445566_actuator_6",
|
||||
"momentary": None,
|
||||
"name": "Konnected 445566 Actuator 6",
|
||||
"pause": None,
|
||||
"repeat": None,
|
||||
"state": None,
|
||||
"zone": "6",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_create_and_setup_pro(hass: HomeAssistant, mock_panel) -> None:
|
||||
"""Test that we create a Konnected Pro Panel and save the data."""
|
||||
device_config = config_flow.CONFIG_ENTRY_SCHEMA(
|
||||
{
|
||||
"host": "1.2.3.4",
|
||||
"port": 1234,
|
||||
"id": "112233445566",
|
||||
"model": "Konnected Pro",
|
||||
"access_token": "11223344556677889900",
|
||||
"default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
|
||||
}
|
||||
)
|
||||
|
||||
device_options = config_flow.OPTIONS_SCHEMA(
|
||||
{
|
||||
"io": {
|
||||
"2": "Binary Sensor",
|
||||
"6": "Binary Sensor",
|
||||
"10": "Binary Sensor",
|
||||
"11": "Binary Sensor",
|
||||
"3": "Digital Sensor",
|
||||
"7": "Digital Sensor",
|
||||
"4": "Switchable Output",
|
||||
"8": "Switchable Output",
|
||||
"out1": "Switchable Output",
|
||||
"alarm1": "Switchable Output",
|
||||
},
|
||||
"binary_sensors": [
|
||||
{"zone": "2", "type": "door"},
|
||||
{"zone": "6", "type": "window", "name": "winder", "inverse": True},
|
||||
{"zone": "10", "type": "door"},
|
||||
{"zone": "11", "type": "window"},
|
||||
],
|
||||
"sensors": [
|
||||
{"zone": "3", "type": "dht", "poll_interval": 5},
|
||||
{"zone": "7", "type": "ds18b20", "poll_interval": 1, "name": "temper"},
|
||||
],
|
||||
"switches": [
|
||||
{"zone": "4"},
|
||||
{
|
||||
"zone": "8",
|
||||
"name": "switcher",
|
||||
"activation": "low",
|
||||
"momentary": 50,
|
||||
"pause": 100,
|
||||
"repeat": 4,
|
||||
},
|
||||
{"zone": "out1"},
|
||||
{"zone": "alarm1"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain="konnected",
|
||||
title="Konnected Pro Alarm Panel",
|
||||
data=device_config,
|
||||
options=device_options,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# setup the integration and inspect panel behavior
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
panel.DOMAIN,
|
||||
{
|
||||
panel.DOMAIN: {
|
||||
panel.CONF_ACCESS_TOKEN: "arandomstringvalue",
|
||||
panel.CONF_API_HOST: "http://192.168.1.1:8123",
|
||||
}
|
||||
},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# confirm panel instance was created and configured
|
||||
# hass.data is the only mechanism to get a reference to the created panel instance
|
||||
device = hass.data[panel.DOMAIN][panel.CONF_DEVICES]["112233445566"]["panel"]
|
||||
await device.update_switch("2", 1)
|
||||
|
||||
# confirm the correct api is used
|
||||
assert mock_panel.put_device.call_count == 0
|
||||
assert mock_panel.put_zone.call_count == 1
|
||||
|
||||
# confirm the settings are sent to the panel
|
||||
assert mock_panel.put_settings.call_args_list[0][1] == {
|
||||
"sensors": [{"zone": "2"}, {"zone": "6"}, {"zone": "10"}, {"zone": "11"}],
|
||||
"actuators": [
|
||||
{"trigger": 1, "zone": "4"},
|
||||
{"trigger": 0, "zone": "8"},
|
||||
{"trigger": 1, "zone": "out1"},
|
||||
{"trigger": 1, "zone": "alarm1"},
|
||||
],
|
||||
"dht_sensors": [{"poll_interval": 5, "zone": "3"}],
|
||||
"ds18b20_sensors": [{"poll_interval": 1, "zone": "7"}],
|
||||
"auth_token": "11223344556677889900",
|
||||
"blink": True,
|
||||
"discovery": True,
|
||||
"endpoint": "http://192.168.1.1:8123/api/konnected",
|
||||
}
|
||||
|
||||
# confirm the device settings are saved in hass.data
|
||||
# hass.data should not be accessed in tests as its considered integration internals
|
||||
assert device.stored_configuration == {
|
||||
"binary_sensors": {
|
||||
"10": {
|
||||
"entity_id": (
|
||||
"binary_sensor.konnected_pro_alarm_panel_konnected_445566_zone_10"
|
||||
),
|
||||
"inverse": False,
|
||||
"name": "Konnected 445566 Zone 10",
|
||||
"state": None,
|
||||
"type": "door",
|
||||
},
|
||||
"11": {
|
||||
"entity_id": (
|
||||
"binary_sensor.konnected_pro_alarm_panel_konnected_445566_zone_11"
|
||||
),
|
||||
"inverse": False,
|
||||
"name": "Konnected 445566 Zone 11",
|
||||
"state": None,
|
||||
"type": "window",
|
||||
},
|
||||
"2": {
|
||||
"entity_id": (
|
||||
"binary_sensor.konnected_pro_alarm_panel_konnected_445566_zone_2"
|
||||
),
|
||||
"inverse": False,
|
||||
"name": "Konnected 445566 Zone 2",
|
||||
"state": None,
|
||||
"type": "door",
|
||||
},
|
||||
"6": {
|
||||
"entity_id": "binary_sensor.konnected_pro_alarm_panel_winder",
|
||||
"inverse": True,
|
||||
"name": "winder",
|
||||
"state": None,
|
||||
"type": "window",
|
||||
},
|
||||
},
|
||||
"blink": True,
|
||||
"discovery": True,
|
||||
"host": "1.2.3.4",
|
||||
"panel": device,
|
||||
"port": 1234,
|
||||
"sensors": [
|
||||
{
|
||||
"humidity": (
|
||||
"sensor"
|
||||
".konnected_pro_alarm_panel_konnected_445566"
|
||||
"_sensor_3_humidity"
|
||||
),
|
||||
"name": "Konnected 445566 Sensor 3",
|
||||
"poll_interval": 5,
|
||||
"temperature": (
|
||||
"sensor"
|
||||
".konnected_pro_alarm_panel_konnected_445566"
|
||||
"_sensor_3_temperature"
|
||||
),
|
||||
"type": "dht",
|
||||
"zone": "3",
|
||||
},
|
||||
{"name": "temper", "poll_interval": 1, "type": "ds18b20", "zone": "7"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"activation": "high",
|
||||
"entity_id": (
|
||||
"switch.konnected_pro_alarm_panel_konnected_445566_actuator_4"
|
||||
),
|
||||
"momentary": None,
|
||||
"name": "Konnected 445566 Actuator 4",
|
||||
"pause": None,
|
||||
"repeat": None,
|
||||
"state": None,
|
||||
"zone": "4",
|
||||
},
|
||||
{
|
||||
"activation": "low",
|
||||
"entity_id": "switch.konnected_pro_alarm_panel_switcher",
|
||||
"momentary": 50,
|
||||
"name": "switcher",
|
||||
"pause": 100,
|
||||
"repeat": 4,
|
||||
"state": None,
|
||||
"zone": "8",
|
||||
},
|
||||
{
|
||||
"activation": "high",
|
||||
"entity_id": (
|
||||
"switch.konnected_pro_alarm_panel_konnected_445566_actuator_out1"
|
||||
),
|
||||
"momentary": None,
|
||||
"name": "Konnected 445566 Actuator out1",
|
||||
"pause": None,
|
||||
"repeat": None,
|
||||
"state": None,
|
||||
"zone": "out1",
|
||||
},
|
||||
{
|
||||
"activation": "high",
|
||||
"entity_id": (
|
||||
"switch.konnected_pro_alarm_panel_konnected_445566_actuator_alarm1"
|
||||
),
|
||||
"momentary": None,
|
||||
"name": "Konnected 445566 Actuator alarm1",
|
||||
"pause": None,
|
||||
"repeat": None,
|
||||
"state": None,
|
||||
"zone": "alarm1",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_default_options(hass: HomeAssistant, mock_panel) -> None:
|
||||
"""Test that we create a Konnected Panel and save the data."""
|
||||
device_config = config_flow.CONFIG_ENTRY_SCHEMA(
|
||||
{
|
||||
"host": "1.2.3.4",
|
||||
"port": 1234,
|
||||
"id": "112233445566",
|
||||
"model": "Konnected Pro",
|
||||
"access_token": "11223344556677889900",
|
||||
"default_options": config_flow.OPTIONS_SCHEMA(
|
||||
{
|
||||
"io": {
|
||||
"1": "Binary Sensor",
|
||||
"2": "Binary Sensor",
|
||||
"3": "Binary Sensor",
|
||||
"4": "Digital Sensor",
|
||||
"5": "Digital Sensor",
|
||||
"6": "Switchable Output",
|
||||
"out": "Switchable Output",
|
||||
},
|
||||
"binary_sensors": [
|
||||
{"zone": "1", "type": "door"},
|
||||
{
|
||||
"zone": "2",
|
||||
"type": "window",
|
||||
"name": "winder",
|
||||
"inverse": True,
|
||||
},
|
||||
{"zone": "3", "type": "door"},
|
||||
],
|
||||
"sensors": [
|
||||
{"zone": "4", "type": "dht"},
|
||||
{"zone": "5", "type": "ds18b20", "name": "temper"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"zone": "out",
|
||||
"name": "switcher",
|
||||
"activation": "low",
|
||||
"momentary": 50,
|
||||
"pause": 100,
|
||||
"repeat": 4,
|
||||
},
|
||||
{"zone": "6"},
|
||||
],
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain="konnected",
|
||||
title="Konnected Alarm Panel",
|
||||
data=device_config,
|
||||
options={},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# override get_status to reflect non-pro board
|
||||
mock_panel.get_status.return_value = {
|
||||
"hwVersion": "2.3.0",
|
||||
"swVersion": "2.3.1",
|
||||
"heap": 10000,
|
||||
"uptime": 12222,
|
||||
"ip": "192.168.1.90",
|
||||
"port": 9123,
|
||||
"sensors": [],
|
||||
"actuators": [],
|
||||
"dht_sensors": [],
|
||||
"ds18b20_sensors": [],
|
||||
"mac": "11:22:33:44:55:66",
|
||||
"settings": {},
|
||||
}
|
||||
|
||||
# setup the integration and inspect panel behavior
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
panel.DOMAIN,
|
||||
{
|
||||
panel.DOMAIN: {
|
||||
panel.CONF_ACCESS_TOKEN: "arandomstringvalue",
|
||||
panel.CONF_API_HOST: "http://192.168.1.1:8123",
|
||||
}
|
||||
},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# confirm panel instance was created and configured.
|
||||
# hass.data is the only mechanism to get a reference to the created panel instance
|
||||
device = hass.data[panel.DOMAIN][panel.CONF_DEVICES]["112233445566"]["panel"]
|
||||
await device.update_switch("1", 0)
|
||||
|
||||
# confirm the correct api is used
|
||||
assert mock_panel.put_device.call_count == 1
|
||||
assert mock_panel.put_zone.call_count == 0
|
||||
|
||||
# confirm the settings are sent to the panel
|
||||
assert mock_panel.put_settings.call_args_list[0][1] == {
|
||||
"sensors": [{"pin": "1"}, {"pin": "2"}, {"pin": "5"}],
|
||||
"actuators": [{"trigger": 0, "pin": "8"}, {"trigger": 1, "pin": "9"}],
|
||||
"dht_sensors": [{"poll_interval": 3, "pin": "6"}],
|
||||
"ds18b20_sensors": [{"poll_interval": 3, "pin": "7"}],
|
||||
"auth_token": "11223344556677889900",
|
||||
"blink": True,
|
||||
"discovery": True,
|
||||
"endpoint": "http://192.168.1.1:8123/api/konnected",
|
||||
}
|
||||
|
||||
# confirm the device settings are saved in hass.data
|
||||
# This test should not access hass.data since its integration internals
|
||||
assert device.stored_configuration == {
|
||||
"binary_sensors": {
|
||||
"1": {
|
||||
"entity_id": (
|
||||
"binary_sensor.konnected_alarm_panel_konnected_445566_zone_1"
|
||||
),
|
||||
"inverse": False,
|
||||
"name": "Konnected 445566 Zone 1",
|
||||
"state": None,
|
||||
"type": "door",
|
||||
},
|
||||
"2": {
|
||||
"entity_id": "binary_sensor.konnected_alarm_panel_winder",
|
||||
"inverse": True,
|
||||
"name": "winder",
|
||||
"state": None,
|
||||
"type": "window",
|
||||
},
|
||||
"3": {
|
||||
"entity_id": (
|
||||
"binary_sensor.konnected_alarm_panel_konnected_445566_zone_3"
|
||||
),
|
||||
"inverse": False,
|
||||
"name": "Konnected 445566 Zone 3",
|
||||
"state": None,
|
||||
"type": "door",
|
||||
},
|
||||
},
|
||||
"blink": True,
|
||||
"discovery": True,
|
||||
"host": "1.2.3.4",
|
||||
"panel": device,
|
||||
"port": 1234,
|
||||
"sensors": [
|
||||
{
|
||||
"humidity": (
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_humidity"
|
||||
),
|
||||
"name": "Konnected 445566 Sensor 4",
|
||||
"poll_interval": 3,
|
||||
"temperature": (
|
||||
"sensor.konnected_alarm_panel_konnected_445566_sensor_4_temperature"
|
||||
),
|
||||
"type": "dht",
|
||||
"zone": "4",
|
||||
},
|
||||
{"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "5"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"activation": "low",
|
||||
"entity_id": "switch.konnected_alarm_panel_switcher",
|
||||
"momentary": 50,
|
||||
"name": "switcher",
|
||||
"pause": 100,
|
||||
"repeat": 4,
|
||||
"state": None,
|
||||
"zone": "out",
|
||||
},
|
||||
{
|
||||
"activation": "high",
|
||||
"entity_id": "switch.konnected_alarm_panel_konnected_445566_actuator_6",
|
||||
"momentary": None,
|
||||
"name": "Konnected 445566 Actuator 6",
|
||||
"pause": None,
|
||||
"repeat": None,
|
||||
"state": None,
|
||||
"zone": "6",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_connect_retry(hass: HomeAssistant, mock_panel) -> None:
|
||||
"""Test that we create a Konnected Panel and save the data."""
|
||||
device_config = config_flow.CONFIG_ENTRY_SCHEMA(
|
||||
{
|
||||
"host": "1.2.3.4",
|
||||
"port": 1234,
|
||||
"id": "112233445566",
|
||||
"model": "Konnected Pro",
|
||||
"access_token": "11223344556677889900",
|
||||
"default_options": config_flow.OPTIONS_SCHEMA(
|
||||
{
|
||||
"io": {
|
||||
"1": "Binary Sensor",
|
||||
"2": "Binary Sensor",
|
||||
"3": "Binary Sensor",
|
||||
"4": "Digital Sensor",
|
||||
"5": "Digital Sensor",
|
||||
"6": "Switchable Output",
|
||||
"out": "Switchable Output",
|
||||
},
|
||||
"binary_sensors": [
|
||||
{"zone": "1", "type": "door"},
|
||||
{
|
||||
"zone": "2",
|
||||
"type": "window",
|
||||
"name": "winder",
|
||||
"inverse": True,
|
||||
},
|
||||
{"zone": "3", "type": "door"},
|
||||
],
|
||||
"sensors": [
|
||||
{"zone": "4", "type": "dht"},
|
||||
{"zone": "5", "type": "ds18b20", "name": "temper"},
|
||||
],
|
||||
"switches": [
|
||||
{
|
||||
"zone": "out",
|
||||
"name": "switcher",
|
||||
"activation": "low",
|
||||
"momentary": 50,
|
||||
"pause": 100,
|
||||
"repeat": 4,
|
||||
},
|
||||
{"zone": "6"},
|
||||
],
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain="konnected",
|
||||
title="Konnected Alarm Panel",
|
||||
data=device_config,
|
||||
options={},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# fail first 2 attempts, and succeed the third
|
||||
mock_panel.get_status.side_effect = [
|
||||
mock_panel.ClientError,
|
||||
mock_panel.ClientError,
|
||||
{
|
||||
"hwVersion": "2.3.0",
|
||||
"swVersion": "2.3.1",
|
||||
"heap": 10000,
|
||||
"uptime": 12222,
|
||||
"ip": "192.168.1.90",
|
||||
"port": 9123,
|
||||
"sensors": [],
|
||||
"actuators": [],
|
||||
"dht_sensors": [],
|
||||
"ds18b20_sensors": [],
|
||||
"mac": "11:22:33:44:55:66",
|
||||
"model": "Konnected Pro",
|
||||
"settings": {},
|
||||
},
|
||||
]
|
||||
|
||||
# setup the integration and inspect panel behavior
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
panel.DOMAIN,
|
||||
{
|
||||
panel.DOMAIN: {
|
||||
panel.CONF_ACCESS_TOKEN: "arandomstringvalue",
|
||||
panel.CONF_API_HOST: "http://192.168.1.1:8123",
|
||||
}
|
||||
},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# confirm switch is unavailable after initial attempt
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("switch.konnected_445566_actuator_6").state == "unavailable"
|
||||
|
||||
# confirm switch is unavailable after second attempt
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(seconds=11))
|
||||
await hass.async_block_till_done()
|
||||
await async_update_entity(hass, "switch.konnected_445566_actuator_6")
|
||||
assert hass.states.get("switch.konnected_445566_actuator_6").state == "unavailable"
|
||||
|
||||
# confirm switch is available after third attempt
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(seconds=21))
|
||||
await hass.async_block_till_done()
|
||||
await async_update_entity(hass, "switch.konnected_445566_actuator_6")
|
||||
assert hass.states.get("switch.konnected_445566_actuator_6").state == "unknown"
|
||||
@@ -1,5 +1,6 @@
|
||||
"""The tests for the Light component."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
@@ -1708,42 +1709,50 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
assert data == {"brightness": 128, "color_temp_kelvin": 3451}
|
||||
|
||||
|
||||
async def test_light_service_call_color_conversion_named_tuple(
|
||||
@pytest.mark.parametrize(
|
||||
"color_input",
|
||||
[
|
||||
pytest.param(
|
||||
{"color_name": "maroon"},
|
||||
id="color_name",
|
||||
),
|
||||
pytest.param(
|
||||
{"rgb_color": color_util.RGBColor(128, 0, 0)},
|
||||
id="rgb_color_named_tuple",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_light_turn_on_rgb_color_is_plain_tuple(
|
||||
hass: HomeAssistant,
|
||||
color_input: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test a named tuple (RGBColor) is handled correctly."""
|
||||
"""Test that rgb_color passed to entity turn_on is always a plain tuple.
|
||||
|
||||
Covers two input paths that both resolve to the same RGB value (128, 0, 0):
|
||||
- color_name: goes through color_name_to_rgb (returns RGBColor NamedTuple),
|
||||
bypassing the service schema vol.Coerce(tuple) coercion.
|
||||
- rgb_color: RGBColor NamedTuple passed directly, converted by the schema.
|
||||
"""
|
||||
entities = [
|
||||
MockLight("Test_hs", STATE_ON),
|
||||
MockLight("Test_rgb", STATE_ON),
|
||||
MockLight("Test_xy", STATE_ON),
|
||||
MockLight("Test_all", STATE_ON),
|
||||
MockLight("Test_rgbw", STATE_ON),
|
||||
MockLight("Test_rgbww", STATE_ON),
|
||||
MockLight("Test_hs", STATE_ON, supported_color_modes={light.ColorMode.HS}),
|
||||
MockLight("Test_rgb", STATE_ON, supported_color_modes={light.ColorMode.RGB}),
|
||||
MockLight("Test_xy", STATE_ON, supported_color_modes={light.ColorMode.XY}),
|
||||
MockLight(
|
||||
"Test_all",
|
||||
STATE_ON,
|
||||
supported_color_modes={
|
||||
light.ColorMode.HS,
|
||||
light.ColorMode.RGB,
|
||||
light.ColorMode.XY,
|
||||
},
|
||||
),
|
||||
MockLight("Test_rgbw", STATE_ON, supported_color_modes={light.ColorMode.RGBW}),
|
||||
MockLight(
|
||||
"Test_rgbww", STATE_ON, supported_color_modes={light.ColorMode.RGBWW}
|
||||
),
|
||||
]
|
||||
setup_test_component_platform(hass, light.DOMAIN, entities)
|
||||
|
||||
entity0 = entities[0]
|
||||
entity0.supported_color_modes = {light.ColorMode.HS}
|
||||
|
||||
entity1 = entities[1]
|
||||
entity1.supported_color_modes = {light.ColorMode.RGB}
|
||||
|
||||
entity2 = entities[2]
|
||||
entity2.supported_color_modes = {light.ColorMode.XY}
|
||||
|
||||
entity3 = entities[3]
|
||||
entity3.supported_color_modes = {
|
||||
light.ColorMode.HS,
|
||||
light.ColorMode.RGB,
|
||||
light.ColorMode.XY,
|
||||
}
|
||||
|
||||
entity4 = entities[4]
|
||||
entity4.supported_color_modes = {light.ColorMode.RGBW}
|
||||
|
||||
entity5 = entities[5]
|
||||
entity5.supported_color_modes = {light.ColorMode.RGBWW}
|
||||
|
||||
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -1751,30 +1760,25 @@ async def test_light_service_call_color_conversion_named_tuple(
|
||||
"light",
|
||||
"turn_on",
|
||||
{
|
||||
"entity_id": [
|
||||
entity0.entity_id,
|
||||
entity1.entity_id,
|
||||
entity2.entity_id,
|
||||
entity3.entity_id,
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
],
|
||||
"entity_id": [entity.entity_id for entity in entities],
|
||||
"brightness_pct": 25,
|
||||
"rgb_color": color_util.RGBColor(128, 0, 0),
|
||||
**color_input,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
_, data = entity0.last_call("turn_on")
|
||||
_, data = entities[0].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "hs_color": (0.0, 100.0)}
|
||||
_, data = entity1.last_call("turn_on")
|
||||
_, data = entities[1].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgb_color": (128, 0, 0)}
|
||||
_, data = entity2.last_call("turn_on")
|
||||
assert type(data["rgb_color"]) is tuple
|
||||
_, data = entities[2].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "xy_color": (0.701, 0.299)}
|
||||
_, data = entity3.last_call("turn_on")
|
||||
_, data = entities[3].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgb_color": (128, 0, 0)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert type(data["rgb_color"]) is tuple
|
||||
_, data = entities[4].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgbw_color": (128, 0, 0, 0)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
_, data = entities[5].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgbww_color": (128, 0, 0, 0, 0)}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"wifi_credentials_set": true,
|
||||
"thread_credentials_set": false,
|
||||
"min_supported_schema_version": 1,
|
||||
"bluetooth_enabled": false
|
||||
"bluetooth_enabled": false,
|
||||
"wifi_ssid": "test_ssid",
|
||||
"ble_proxy_enabled": false
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"wifi_credentials_set": true,
|
||||
"thread_credentials_set": false,
|
||||
"min_supported_schema_version": 1,
|
||||
"bluetooth_enabled": false
|
||||
"bluetooth_enabled": false,
|
||||
"wifi_ssid": "**REDACTED**",
|
||||
"ble_proxy_enabled": false
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
|
||||
@@ -9,8 +9,12 @@ from matter_server.common.helpers.util import dataclass_from_dict
|
||||
from matter_server.common.models import ServerDiagnostics
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.components.matter.const import DOMAIN
|
||||
from homeassistant.components.matter.diagnostics import redact_matter_attributes
|
||||
from homeassistant.components.matter.diagnostics import (
|
||||
SERVER_INFO_TO_REDACT,
|
||||
redact_matter_attributes,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
@@ -85,7 +89,7 @@ async def test_device_diagnostics(
|
||||
"""Test the device diagnostics."""
|
||||
system_info_dict = config_entry_diagnostics["info"]
|
||||
device_diagnostics_redacted = {
|
||||
"server_info": system_info_dict,
|
||||
"server_info": async_redact_data(system_info_dict, SERVER_INFO_TO_REDACT),
|
||||
"node": redact_matter_attributes(device_diagnostics),
|
||||
}
|
||||
server_diagnostics_response = {
|
||||
|
||||
@@ -749,7 +749,18 @@ MOCK_SUBENTRY_DEVICE_DATA = {
|
||||
}
|
||||
|
||||
MOCK_NOTIFY_SUBENTRY_DATA_MULTI = {
|
||||
"device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}},
|
||||
"device": MOCK_SUBENTRY_DEVICE_DATA
|
||||
| {
|
||||
"mqtt_settings": {
|
||||
"qos": 2.0,
|
||||
"message_expiry_interval": {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 1,
|
||||
"seconds": 30,
|
||||
},
|
||||
}
|
||||
},
|
||||
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2,
|
||||
} | MOCK_SUBENTRY_AVAILABILITY_DATA
|
||||
|
||||
@@ -882,7 +893,18 @@ MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = {
|
||||
"components": MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA,
|
||||
}
|
||||
MOCK_SUBENTRY_DATA_SET_MIX = {
|
||||
"device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}},
|
||||
"device": MOCK_SUBENTRY_DEVICE_DATA
|
||||
| {
|
||||
"mqtt_settings": {
|
||||
"qos": 0,
|
||||
"message_expiry_interval": {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 1,
|
||||
"seconds": 30,
|
||||
},
|
||||
}
|
||||
},
|
||||
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1
|
||||
| MOCK_SUBENTRY_NOTIFY_COMPONENT2
|
||||
| MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT
|
||||
|
||||
@@ -88,6 +88,66 @@ async def test_sending_mqtt_commands(
|
||||
assert state.state == "2021-11-08T13:31:44+00:00"
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2021-11-08 13:31:44+00:00")
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
{
|
||||
mqtt.DOMAIN: {
|
||||
button.DOMAIN: {
|
||||
"command_topic": "command-topic",
|
||||
"name": "test",
|
||||
"default_entity_id": "button.test_button",
|
||||
"payload_press": "beer press",
|
||||
"qos": "2",
|
||||
"message_expiry_interval": {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 1,
|
||||
"seconds": 30,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
mqtt.DOMAIN: {
|
||||
button.DOMAIN: {
|
||||
"command_topic": "command-topic",
|
||||
"name": "test",
|
||||
"default_entity_id": "button.test_button",
|
||||
"payload_press": "beer press",
|
||||
"qos": "2",
|
||||
"message_expiry_interval": 90,
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_sending_mqtt_commands_with_message_expiry_interval(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
) -> None:
|
||||
"""Test the sending MQTT command with message expiry interval."""
|
||||
mqtt_mock = await mqtt_mock_entry()
|
||||
|
||||
state = hass.states.get("button.test_button")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test"
|
||||
|
||||
await hass.services.async_call(
|
||||
button.DOMAIN,
|
||||
button.SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: "button.test_button"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mqtt_mock.async_publish.assert_called_once_with(
|
||||
"command-topic", "beer press", 2, False, message_expiry_interval=90
|
||||
)
|
||||
mqtt_mock.async_publish.reset_mock()
|
||||
state = hass.states.get("button.test_button")
|
||||
assert state.state == "2021-11-08T13:31:44+00:00"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
|
||||
@@ -1502,12 +1502,13 @@ async def test_publish_error(
|
||||
|
||||
async def test_subscribe_error(
|
||||
hass: HomeAssistant,
|
||||
setup_with_birth_msg_client_mock: MqttMockPahoClient,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
mqtt_client_mock: MqttMockPahoClient,
|
||||
record_calls: MessageCallbackType,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test publish error."""
|
||||
mqtt_client_mock = setup_with_birth_msg_client_mock
|
||||
await mqtt_mock_entry()
|
||||
mqtt_client_mock.reset_mock()
|
||||
# simulate client is not connected error before subscribing
|
||||
mqtt_client_mock.subscribe.side_effect = lambda *args, **kwargs: (4, None)
|
||||
|
||||
@@ -5196,7 +5196,14 @@ async def test_subentry_reconfigure_update_device_properties(
|
||||
.schema["mqtt_settings"]
|
||||
.schema.schema.items()
|
||||
}
|
||||
assert mqtt_settings_key_descriptions == {"qos": {"suggested_value": 2}}
|
||||
assert mqtt_settings_key_descriptions == {
|
||||
"qos": {
|
||||
"suggested_value": 2,
|
||||
},
|
||||
"message_expiry_interval": {
|
||||
"suggested_value": {"days": 0, "hours": 0, "minutes": 1, "seconds": 30}
|
||||
},
|
||||
}
|
||||
assert result["data_schema"].schema["mqtt_settings"].options == {"collapsed": False}
|
||||
|
||||
# Update the device details
|
||||
@@ -5209,7 +5216,15 @@ async def test_subentry_reconfigure_update_device_properties(
|
||||
"model_id": "bn003",
|
||||
"manufacturer": "Beer Masters",
|
||||
"configuration_url": "https://example.com",
|
||||
"mqtt_settings": {"qos": 1},
|
||||
"mqtt_settings": {
|
||||
"qos": 1,
|
||||
"message_expiry_interval": {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 0,
|
||||
"seconds": 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
@@ -5232,6 +5247,12 @@ async def test_subentry_reconfigure_update_device_properties(
|
||||
assert device["sw_version"] == "1.1"
|
||||
assert device["manufacturer"] == "Beer Masters"
|
||||
assert device["mqtt_settings"]["qos"] == 1
|
||||
assert device["mqtt_settings"]["message_expiry_interval"] == {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 0,
|
||||
"seconds": 30,
|
||||
}
|
||||
assert "qos" not in device
|
||||
|
||||
|
||||
|
||||
@@ -126,6 +126,36 @@ def mock_websocket_client(
|
||||
message="Data listener registered",
|
||||
data={EventKey.MODULES: register_data_listener_model.modules},
|
||||
)
|
||||
websocket_client.open_url.return_value = Response(
|
||||
id=FIXTURE_REQUEST_ID,
|
||||
type=EventType.OPENED,
|
||||
message="Opened url",
|
||||
data={"url": "https://example.com"},
|
||||
)
|
||||
websocket_client.open_path.return_value = Response(
|
||||
id=FIXTURE_REQUEST_ID,
|
||||
type=EventType.OPENED,
|
||||
message="Opened file",
|
||||
data={"path": "/home/user/documents"},
|
||||
)
|
||||
websocket_client.power_shutdown.return_value = Response(
|
||||
id=FIXTURE_REQUEST_ID,
|
||||
type=EventType.POWER_SHUTDOWN,
|
||||
message="Shutdown",
|
||||
data={},
|
||||
)
|
||||
websocket_client.keyboard_keypress.return_value = Response(
|
||||
id=FIXTURE_REQUEST_ID,
|
||||
type=EventType.KEYBOARD_KEY_PRESSED,
|
||||
message="Keyboard key pressed",
|
||||
data={"key": "backspace"},
|
||||
)
|
||||
websocket_client.keyboard_text.return_value = Response(
|
||||
id=FIXTURE_REQUEST_ID,
|
||||
type=EventType.KEYBOARD_TEXT_SENT,
|
||||
message="Keyboard text sent",
|
||||
data={"text": "Hello world"},
|
||||
)
|
||||
# Trigger callback when listener is registered
|
||||
websocket_client.listen.side_effect = mock_data_listener
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# serializer version: 1
|
||||
# name: test_get_process_services[get_process_by_id]
|
||||
dict({
|
||||
'cpu_usage': 12.3,
|
||||
'created': 12.3,
|
||||
'id': 1234,
|
||||
'memory_usage': 12.3,
|
||||
'name': 'name',
|
||||
'path': '/path',
|
||||
'status': 'running',
|
||||
'username': 'username',
|
||||
'working_directory': '/working/directory',
|
||||
})
|
||||
# ---
|
||||
# name: test_get_process_services[get_processes_by_name]
|
||||
dict({
|
||||
'count': 1,
|
||||
'processes': list([
|
||||
dict({
|
||||
'cpu_usage': 12.3,
|
||||
'created': 12.3,
|
||||
'id': 1234,
|
||||
'memory_usage': 12.3,
|
||||
'name': 'name',
|
||||
'path': '/path',
|
||||
'status': 'running',
|
||||
'username': 'username',
|
||||
'working_directory': '/working/directory',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
# ---
|
||||
# name: test_services[open_path]
|
||||
dict({
|
||||
'data': dict({
|
||||
'path': '/home/user/documents',
|
||||
}),
|
||||
'id': 'test',
|
||||
'message': 'Opened file',
|
||||
'module': None,
|
||||
'subtype': None,
|
||||
'type': <EventType.OPENED: 'OPENED'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_services[open_url]
|
||||
dict({
|
||||
'data': dict({
|
||||
'url': 'https://example.com',
|
||||
}),
|
||||
'id': 'test',
|
||||
'message': 'Opened url',
|
||||
'module': None,
|
||||
'subtype': None,
|
||||
'type': <EventType.OPENED: 'OPENED'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_services[power_command_shutdown]
|
||||
dict({
|
||||
'data': dict({
|
||||
}),
|
||||
'id': 'test',
|
||||
'message': 'Shutdown',
|
||||
'module': None,
|
||||
'subtype': None,
|
||||
'type': <EventType.POWER_SHUTDOWN: 'POWER_SHUTDOWN'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_services[send_keypress]
|
||||
dict({
|
||||
'data': dict({
|
||||
'key': 'backspace',
|
||||
}),
|
||||
'id': 'test',
|
||||
'message': 'Keyboard key pressed',
|
||||
'module': None,
|
||||
'subtype': None,
|
||||
'type': <EventType.KEYBOARD_KEY_PRESSED: 'KEYBOARD_KEY_PRESSED'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_services[send_text]
|
||||
dict({
|
||||
'data': dict({
|
||||
'text': 'Hello world',
|
||||
}),
|
||||
'id': 'test',
|
||||
'message': 'Keyboard text sent',
|
||||
'module': None,
|
||||
'subtype': None,
|
||||
'type': <EventType.KEYBOARD_TEXT_SENT: 'KEYBOARD_TEXT_SENT'>,
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for System Bridge actions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from systembridgeconnector.models.keyboard_key import KeyboardKey
|
||||
from systembridgeconnector.models.keyboard_text import KeyboardText
|
||||
from systembridgeconnector.models.open_path import OpenPath
|
||||
from systembridgeconnector.models.open_url import OpenUrl
|
||||
|
||||
from homeassistant.components.system_bridge.const import DOMAIN
|
||||
from homeassistant.components.system_bridge.services import (
|
||||
CONF_BRIDGE,
|
||||
CONF_KEY,
|
||||
CONF_TEXT,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_COMMAND, CONF_ID, CONF_NAME, CONF_PATH, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import FIXTURE_UUID
|
||||
|
||||
from tests.common import AsyncMock, MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "service_data", "call_method", "call_args"),
|
||||
[
|
||||
(
|
||||
"open_path",
|
||||
{CONF_PATH: "/home/user/documents"},
|
||||
"open_path",
|
||||
[OpenPath(path="/home/user/documents")],
|
||||
),
|
||||
(
|
||||
"open_url",
|
||||
{CONF_URL: "https://example.com"},
|
||||
"open_url",
|
||||
[OpenUrl(url="https://example.com")],
|
||||
),
|
||||
(
|
||||
"power_command",
|
||||
{CONF_COMMAND: "shutdown"},
|
||||
"power_shutdown",
|
||||
[],
|
||||
),
|
||||
(
|
||||
"send_keypress",
|
||||
{CONF_KEY: "backspace"},
|
||||
"keyboard_keypress",
|
||||
[KeyboardKey(key="backspace")],
|
||||
),
|
||||
(
|
||||
"send_text",
|
||||
{CONF_TEXT: "Hello world"},
|
||||
"keyboard_text",
|
||||
[KeyboardText(text="Hello world")],
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"open_path",
|
||||
"open_url",
|
||||
"power_command_shutdown",
|
||||
"send_keypress",
|
||||
"send_text",
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_version")
|
||||
async def test_services(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_websocket_client: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
service: str,
|
||||
service_data: dict[str, Any],
|
||||
call_method: str,
|
||||
call_args: list[Any],
|
||||
) -> None:
|
||||
"""Test System Bridge service action calls."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, FIXTURE_UUID)}
|
||||
)
|
||||
assert device_entry
|
||||
|
||||
resp = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
service,
|
||||
{
|
||||
CONF_BRIDGE: device_entry.id,
|
||||
**service_data,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
getattr(mock_websocket_client, call_method).assert_awaited_once_with(*call_args)
|
||||
assert resp == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "service_data"),
|
||||
[
|
||||
(
|
||||
"get_process_by_id",
|
||||
{CONF_ID: 1234},
|
||||
),
|
||||
(
|
||||
"get_processes_by_name",
|
||||
{CONF_NAME: "name"},
|
||||
),
|
||||
],
|
||||
ids=["get_process_by_id", "get_processes_by_name"],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_version", "mock_websocket_client")
|
||||
async def test_get_process_services(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
service: str,
|
||||
service_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test System Bridge get process service action calls."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, FIXTURE_UUID)}
|
||||
)
|
||||
assert device_entry
|
||||
|
||||
resp = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
service,
|
||||
{
|
||||
CONF_BRIDGE: device_entry.id,
|
||||
**service_data,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert resp == snapshot
|
||||
+3
-1
@@ -1074,7 +1074,9 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]:
|
||||
|
||||
@ha.callback
|
||||
def _async_fire_mqtt_message(topic, payload, qos, retain, properties=None):
|
||||
async_fire_mqtt_message(hass, topic, payload or b"", qos, retain)
|
||||
async_fire_mqtt_message(
|
||||
hass, topic, payload or b"", qos, retain, properties=properties
|
||||
)
|
||||
mid = get_mid()
|
||||
hass.loop.call_soon(
|
||||
mock_client.on_publish, Mock(), 0, mid, MockMqttReasonCode(), None
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
"""Tests for the split_tests cache logic."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from script import split_tests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree(tmp_path: Path) -> Path:
|
||||
"""Build a tree: root conftest, two integrations, a ``common.py`` helper."""
|
||||
# Bound the ancestor-fixture walk so it doesn't escape tmp_path.
|
||||
(tmp_path / "pyproject.toml").write_text("")
|
||||
(tmp_path / "conftest.py").write_text("# tests/conftest.py\n")
|
||||
(tmp_path / "common.py").write_text("# helper module\n")
|
||||
|
||||
alpha_dir = tmp_path / "components" / "alpha"
|
||||
alpha_dir.mkdir(parents=True)
|
||||
(alpha_dir / "conftest.py").write_text("# alpha conftest\n")
|
||||
(alpha_dir / "test_one.py").write_text("def test_a():\n pass\n")
|
||||
(alpha_dir / "test_two.py").write_text("def test_b():\n pass\n")
|
||||
|
||||
beta_dir = tmp_path / "components" / "beta"
|
||||
beta_dir.mkdir()
|
||||
(beta_dir / "test_x.py").write_text("def test_x():\n pass\n")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_iter_eligible_children_filters_helpers(tree: Path) -> None:
|
||||
"""Helper files like conftest.py and common.py are not collection targets."""
|
||||
children = split_tests._iter_eligible_children(tree)
|
||||
names = {p.name for p in children}
|
||||
assert "common.py" not in names
|
||||
assert "conftest.py" not in names
|
||||
# components/ is a dir, gets included.
|
||||
assert "components" in names
|
||||
|
||||
|
||||
def test_enumerate_batch_paths_fans_out_components(tree: Path) -> None:
|
||||
"""tests/components fans out one level deeper into per-integration paths."""
|
||||
paths = split_tests._enumerate_batch_paths(tree)
|
||||
rel = {p.relative_to(tree).as_posix() for p in paths}
|
||||
assert rel == {"components/beta", "components/alpha"}
|
||||
|
||||
|
||||
def test_enumerate_batch_paths_for_single_file(tmp_path: Path) -> None:
|
||||
"""A test file passed directly is returned as-is."""
|
||||
file = tmp_path / "test_solo.py"
|
||||
file.write_text("def test_x(): pass\n")
|
||||
assert split_tests._enumerate_batch_paths(file) == [file]
|
||||
|
||||
|
||||
def _fixture_hash_for(tree: Path, file: Path) -> str:
|
||||
"""Compute the fixture scope hash for ``file`` rooted at ``tree``."""
|
||||
_, fixtures = split_tests._walk_test_tree(tree)
|
||||
fixtures_by_dir = split_tests._build_fixtures_by_dir(tree, fixtures)
|
||||
return split_tests._file_fixture_hash(file, tree, fixtures_by_dir)
|
||||
|
||||
|
||||
def _prime_cache(
|
||||
cache_path: Path,
|
||||
tree: Path,
|
||||
hits: dict[Path, int] | None = None,
|
||||
extra_entries: dict[str, split_tests._CacheEntry] | None = None,
|
||||
) -> None:
|
||||
"""Save a cache for ``tree`` keyed on real file and fixture hashes.
|
||||
|
||||
``hits`` maps file → cached count (hashed for real, so the next
|
||||
run resolves as a hit). ``extra_entries`` injects raw entries
|
||||
whose path may not exist on disk (eg ghost files).
|
||||
"""
|
||||
entries: dict[str, split_tests._CacheEntry] = {
|
||||
str(file.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(file),
|
||||
fixture_hash=_fixture_hash_for(tree, file),
|
||||
count=count,
|
||||
)
|
||||
for file, count in (hits or {}).items()
|
||||
}
|
||||
if extra_entries:
|
||||
entries.update(extra_entries)
|
||||
split_tests._Cache(entries=entries).save(cache_path)
|
||||
|
||||
|
||||
def _echo_one_test_each(
|
||||
skip: set[Path] | None = None,
|
||||
) -> Callable[[list[Path]], list[tuple[str, str, int]]]:
|
||||
"""Fake ``_run_collect_batches``: 1 test per path; ``skip`` paths drop out."""
|
||||
skip = skip or set()
|
||||
|
||||
def fake(paths: list[Path]) -> list[tuple[str, str, int]]:
|
||||
emitted = [p for p in paths if p not in skip]
|
||||
return [("\n".join(f"{p}: 1" for p in emitted) + "\n", "", 0)]
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
def test_file_fixture_hash_changes_when_ancestor_conftest_changes(tree: Path) -> None:
|
||||
"""A conftest edit in the file's ancestor chain busts that file's hash."""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
before = _fixture_hash_for(tree, alpha_one)
|
||||
# Same-dir conftest is an ancestor of alpha_one.
|
||||
(tree / "components" / "alpha" / "conftest.py").write_text("# changed\n")
|
||||
after = _fixture_hash_for(tree, alpha_one)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_file_fixture_hash_changes_when_same_dir_helper_changes(tree: Path) -> None:
|
||||
"""A non-conftest helper in the same dir busts the file's hash."""
|
||||
alpha_dir = tree / "components" / "alpha"
|
||||
(alpha_dir / "common.py").write_text("# helper v1\n")
|
||||
alpha_one = alpha_dir / "test_one.py"
|
||||
before = _fixture_hash_for(tree, alpha_one)
|
||||
(alpha_dir / "common.py").write_text("# helper v2\n")
|
||||
after = _fixture_hash_for(tree, alpha_one)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_file_fixture_hash_isolated_from_sibling_dir(tree: Path) -> None:
|
||||
"""A helper change in a sibling subtree leaves this file's hash alone."""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
before = _fixture_hash_for(tree, alpha_one)
|
||||
# beta is a sibling of alpha (not an ancestor), so its helper edit
|
||||
# must not affect alpha_one's fixture hash.
|
||||
(tree / "components" / "beta" / "common.py").write_text("# beta v2\n")
|
||||
after = _fixture_hash_for(tree, alpha_one)
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_file_fixture_hash_changes_when_ancestor_helper_changes(tree: Path) -> None:
|
||||
"""A helper edit anywhere on the ancestor path busts the file's hash.
|
||||
|
||||
Test files often import VALUES for ``@pytest.mark.parametrize`` from
|
||||
shared helpers like ``tests/components/common.py``; any ancestor
|
||||
``.py`` change has to invalidate descendants so cached counts don't
|
||||
drift after edits to those sources.
|
||||
"""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
# Seed a shared helper one level up from alpha.
|
||||
components_common = tree / "components" / "common.py"
|
||||
components_common.write_text("# helper v1\n")
|
||||
before = _fixture_hash_for(tree, alpha_one)
|
||||
components_common.write_text("# helper v2\n")
|
||||
after = _fixture_hash_for(tree, alpha_one)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_file_fixture_hash_stable_for_test_changes(tree: Path) -> None:
|
||||
"""Test-file edits do not invalidate the file's fixture hash."""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
before = _fixture_hash_for(tree, alpha_one)
|
||||
alpha_one.write_text("def test_a():\n pass\n\ndef test_c():\n pass\n")
|
||||
after = _fixture_hash_for(tree, alpha_one)
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_find_ancestor_fixtures_stops_at_project_root(tmp_path: Path) -> None:
|
||||
"""A project-root marker bounds the ancestor walk."""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / "pyproject.toml").write_text("")
|
||||
(project / "common.py").write_text("# included\n")
|
||||
nested = project / "tests" / "x"
|
||||
nested.mkdir(parents=True)
|
||||
# Above the project root: must NOT be picked up.
|
||||
(tmp_path / "outside.py").write_text("# excluded\n")
|
||||
|
||||
found = {p.name for p in split_tests._find_ancestor_fixtures(nested)}
|
||||
assert "common.py" in found
|
||||
assert "outside.py" not in found
|
||||
|
||||
|
||||
def test_find_ancestor_fixtures_walks_through_gaps(tmp_path: Path) -> None:
|
||||
"""Ancestor conftests + helpers are collected across intermediate gaps."""
|
||||
(tmp_path / "pyproject.toml").write_text("") # bound the walk
|
||||
nested = tmp_path / "a" / "b" / "c"
|
||||
nested.mkdir(parents=True)
|
||||
# ``a/b`` has no fixtures, but ``a`` has both a conftest and a helper.
|
||||
(tmp_path / "a" / "conftest.py").write_text("# a\n")
|
||||
(tmp_path / "a" / "common.py").write_text("# a helper\n")
|
||||
(tmp_path / "a" / "b" / "c" / "conftest.py").write_text("# c\n")
|
||||
|
||||
found = {
|
||||
p.relative_to(tmp_path).as_posix()
|
||||
for p in split_tests._find_ancestor_fixtures(nested)
|
||||
}
|
||||
# The walk starts at ``nested.parent`` (a/b); a/b/c/conftest.py is
|
||||
# not an ancestor. Both ``a/conftest.py`` and ``a/common.py`` must
|
||||
# be found despite a/b having no fixtures of its own.
|
||||
assert "a/conftest.py" in found
|
||||
assert "a/common.py" in found
|
||||
assert "a/b/c/conftest.py" not in found
|
||||
|
||||
|
||||
def test_file_fixture_hash_picks_up_ancestor_helper_above_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An ancestor non-conftest helper above root still busts descendant hashes.
|
||||
|
||||
A subtree run on ``components/`` must still invalidate when a shared
|
||||
helper one level up (eg ``tests/components/common.py``) changes.
|
||||
"""
|
||||
(tmp_path / "pyproject.toml").write_text("") # bound the walk
|
||||
(tmp_path / "common.py").write_text("# v1\n")
|
||||
subtree = tmp_path / "components"
|
||||
subtree.mkdir()
|
||||
test_file = subtree / "test_x.py"
|
||||
test_file.write_text("def test_x(): pass\n")
|
||||
|
||||
before = _fixture_hash_for(subtree, test_file)
|
||||
(tmp_path / "common.py").write_text("# v2\n")
|
||||
after = _fixture_hash_for(subtree, test_file)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_file_fixture_hash_picks_up_ancestor_conftest_across_gap(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An ancestor conftest across a gap still busts the descendant's hash."""
|
||||
(tmp_path / "pyproject.toml").write_text("") # bound the walk
|
||||
nested = tmp_path / "a" / "b"
|
||||
nested.mkdir(parents=True)
|
||||
(tmp_path / "a" / "conftest.py").write_text("# v1\n")
|
||||
test_file = nested / "test_x.py"
|
||||
test_file.write_text("def test_x(): pass\n")
|
||||
|
||||
before = _fixture_hash_for(nested, test_file)
|
||||
(tmp_path / "a" / "conftest.py").write_text("# v2\n")
|
||||
after = _fixture_hash_for(nested, test_file)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_file_fixture_hash_includes_ancestor_above_root(tmp_path: Path) -> None:
|
||||
"""An ancestor conftest above root must still scope a subtree file."""
|
||||
(tmp_path / "pyproject.toml").write_text("") # bound the walk
|
||||
(tmp_path / "conftest.py").write_text("# parent\n")
|
||||
subtree = tmp_path / "components"
|
||||
subtree.mkdir()
|
||||
test_file = subtree / "test_x.py"
|
||||
test_file.write_text("def test_x(): pass\n")
|
||||
|
||||
before = _fixture_hash_for(subtree, test_file)
|
||||
(tmp_path / "conftest.py").write_text("# parent changed\n")
|
||||
after = _fixture_hash_for(subtree, test_file)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_walk_test_tree_separates_tests_from_fixtures(tree: Path) -> None:
|
||||
"""The walker returns test_*.py files and every other .py as fixtures."""
|
||||
test_files, fixtures = split_tests._walk_test_tree(tree)
|
||||
test_names = {p.name for p in test_files}
|
||||
fixture_paths = {p.relative_to(tree).as_posix() for p in fixtures}
|
||||
assert test_names == {"test_one.py", "test_two.py", "test_x.py"}
|
||||
assert fixture_paths == {
|
||||
"conftest.py",
|
||||
"common.py",
|
||||
"components/alpha/conftest.py",
|
||||
}
|
||||
|
||||
|
||||
def test_walk_test_tree_skips_hidden_and_dunder_dirs(tmp_path: Path) -> None:
|
||||
"""Hidden/dunder directories are pruned from the walk."""
|
||||
(tmp_path / "__pycache__").mkdir()
|
||||
(tmp_path / "__pycache__" / "test_ghost.py").write_text("def test_g(): pass\n")
|
||||
(tmp_path / ".hidden").mkdir()
|
||||
(tmp_path / ".hidden" / "test_invisible.py").write_text("def test_h(): pass\n")
|
||||
(tmp_path / "test_real.py").write_text("def test_r(): pass\n")
|
||||
|
||||
test_files, _ = split_tests._walk_test_tree(tmp_path)
|
||||
assert {p.name for p in test_files} == {"test_real.py"}
|
||||
|
||||
|
||||
def test_collect_tests_skips_cache_for_single_file_root(tmp_path: Path) -> None:
|
||||
"""Single-file root bypasses caching.
|
||||
|
||||
Otherwise the invalidation hash would be constant and stale counts
|
||||
could survive conftest edits.
|
||||
"""
|
||||
cache_path = tmp_path / "cache.json"
|
||||
file = tmp_path / "test_solo.py"
|
||||
file.write_text("def test_x(): pass\n")
|
||||
|
||||
with (
|
||||
patch.object(split_tests, "_collect_tests_uncached") as uncached,
|
||||
patch.object(split_tests, "_collect_tests_cached") as cached,
|
||||
):
|
||||
split_tests.collect_tests(file, cache_path)
|
||||
|
||||
uncached.assert_called_once_with(file)
|
||||
cached.assert_not_called()
|
||||
assert not cache_path.exists()
|
||||
|
||||
|
||||
def test_cache_roundtrip(tmp_path: Path) -> None:
|
||||
"""A cache survives save → load."""
|
||||
cache_path = tmp_path / "cache.json"
|
||||
cache = split_tests._Cache(
|
||||
entries={
|
||||
"tests/alpha/test_a.py": split_tests._CacheEntry(
|
||||
hash="h1", fixture_hash="f1", count=5
|
||||
)
|
||||
},
|
||||
)
|
||||
cache.save(cache_path)
|
||||
loaded = split_tests._Cache.load(cache_path)
|
||||
assert loaded.entries == cache.entries
|
||||
|
||||
|
||||
def test_cache_load_missing_returns_empty(tmp_path: Path) -> None:
|
||||
"""A missing cache file degrades gracefully to an empty cache."""
|
||||
cache = split_tests._Cache.load(tmp_path / "missing.json")
|
||||
assert cache.entries == {}
|
||||
|
||||
|
||||
def test_cache_load_invalid_json_returns_empty(tmp_path: Path) -> None:
|
||||
"""Corrupt JSON is treated as a cache miss instead of crashing."""
|
||||
path = tmp_path / "broken.json"
|
||||
path.write_text("{not json")
|
||||
cache = split_tests._Cache.load(path)
|
||||
assert cache.entries == {}
|
||||
|
||||
|
||||
def test_cache_load_wrong_version_returns_empty(tmp_path: Path) -> None:
|
||||
"""An older cache schema is discarded rather than misread."""
|
||||
path = tmp_path / "old.json"
|
||||
path.write_text(json.dumps({"version": 0, "files": {}}))
|
||||
cache = split_tests._Cache.load(path)
|
||||
assert cache.entries == {}
|
||||
|
||||
|
||||
def test_cache_load_drops_malformed_entries(tmp_path: Path) -> None:
|
||||
"""Malformed per-file entries are skipped, valid ones are kept."""
|
||||
path = tmp_path / "cache.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": split_tests._CACHE_VERSION,
|
||||
"files": {
|
||||
"good.py": {"hash": "h1", "fixture_hash": "f1", "count": 3},
|
||||
"bad_count.py": {
|
||||
"hash": "h2",
|
||||
"fixture_hash": "f2",
|
||||
"count": "three",
|
||||
},
|
||||
"missing_hash.py": {"fixture_hash": "f3", "count": 4},
|
||||
"missing_fixture_hash.py": {"hash": "h4", "count": 4},
|
||||
"not_dict.py": 5,
|
||||
# bool is an int subclass; reject so True isn't read as 1.
|
||||
"bool_count.py": {
|
||||
"hash": "h5",
|
||||
"fixture_hash": "f5",
|
||||
"count": True,
|
||||
},
|
||||
"negative_count.py": {
|
||||
"hash": "h6",
|
||||
"fixture_hash": "f6",
|
||||
"count": -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
cache = split_tests._Cache.load(path)
|
||||
assert set(cache.entries) == {"good.py"}
|
||||
|
||||
|
||||
def test_cache_save_creates_parent_dir(tmp_path: Path) -> None:
|
||||
"""Save mkdirs missing parent dirs so ``--cache foo/bar.json`` works."""
|
||||
cache_path = tmp_path / "nested" / "subdir" / "cache.json"
|
||||
split_tests._Cache(entries={}).save(cache_path)
|
||||
assert cache_path.is_file()
|
||||
|
||||
|
||||
def _resolve(
|
||||
test_files: list[Path], cache: split_tests._Cache, tree: Path
|
||||
) -> tuple[dict[Path, split_tests._CacheEntry], list[Path]]:
|
||||
"""Run resolve_entries with a freshly indexed fixtures_by_dir."""
|
||||
_, fixtures = split_tests._walk_test_tree(tree)
|
||||
return split_tests._resolve_entries(
|
||||
test_files,
|
||||
cache,
|
||||
tree,
|
||||
split_tests._build_fixtures_by_dir(tree, fixtures),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_entries_hits_and_misses(tree: Path) -> None:
|
||||
"""Files with matching content + fixture hashes are hits."""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
|
||||
alpha_one_hash = split_tests._hash_file(alpha_one)
|
||||
alpha_one_fixture = _fixture_hash_for(tree, alpha_one)
|
||||
cache = split_tests._Cache(
|
||||
entries={
|
||||
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=alpha_one_hash, fixture_hash=alpha_one_fixture, count=1
|
||||
),
|
||||
str(alpha_two.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash="stale", fixture_hash=alpha_one_fixture, count=99
|
||||
),
|
||||
},
|
||||
)
|
||||
entries, misses = _resolve([alpha_one, alpha_two, beta_x], cache, tree)
|
||||
# Hit: cached entry passed through verbatim.
|
||||
assert entries[alpha_one] == split_tests._CacheEntry(
|
||||
hash=alpha_one_hash, fixture_hash=alpha_one_fixture, count=1
|
||||
)
|
||||
# Misses: fresh hashes plus a count=0 placeholder.
|
||||
assert set(misses) == {alpha_two, beta_x}
|
||||
assert entries[alpha_two].count == 0
|
||||
assert entries[alpha_two].hash == split_tests._hash_file(alpha_two)
|
||||
assert entries[beta_x].count == 0
|
||||
assert entries[beta_x].hash == split_tests._hash_file(beta_x)
|
||||
|
||||
|
||||
def test_resolve_entries_misses_on_fixture_drift(tree: Path) -> None:
|
||||
"""A file with unchanged content but changed scope counts as a miss."""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
cache = split_tests._Cache(
|
||||
entries={
|
||||
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(alpha_one),
|
||||
fixture_hash="stale-fixture-hash",
|
||||
count=1,
|
||||
),
|
||||
},
|
||||
)
|
||||
_, misses = _resolve([alpha_one], cache, tree)
|
||||
assert misses == [alpha_one]
|
||||
|
||||
|
||||
def test_resolve_entries_isolates_unrelated_dirs(tree: Path) -> None:
|
||||
"""Editing a helper in one dir leaves files in other dirs as hits."""
|
||||
alpha_dir = tree / "components" / "alpha"
|
||||
beta_dir = tree / "components" / "beta"
|
||||
# Helpers per dir, so a change in alpha doesn't bust beta.
|
||||
(alpha_dir / "common.py").write_text("# alpha helper v1\n")
|
||||
(beta_dir / "common.py").write_text("# beta helper v1\n")
|
||||
alpha_one = alpha_dir / "test_one.py"
|
||||
beta_x = beta_dir / "test_x.py"
|
||||
|
||||
# Snapshot cache entries with the v1 fixture state.
|
||||
cache = split_tests._Cache(
|
||||
entries={
|
||||
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(alpha_one),
|
||||
fixture_hash=_fixture_hash_for(tree, alpha_one),
|
||||
count=1,
|
||||
),
|
||||
str(beta_x.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(beta_x),
|
||||
fixture_hash=_fixture_hash_for(tree, beta_x),
|
||||
count=2,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Now bust beta's helper; alpha's scope is unchanged, beta's isn't.
|
||||
(beta_dir / "common.py").write_text("# beta helper v2\n")
|
||||
_, misses = _resolve([alpha_one, beta_x], cache, tree)
|
||||
assert misses == [beta_x]
|
||||
|
||||
|
||||
def test_collect_tests_hashes_each_file_once(tree: Path) -> None:
|
||||
"""Hits reuse the stored hash, misses reuse the resolve-time hash.
|
||||
|
||||
Guards against regressing the double-read on cache-miss rebuilds:
|
||||
each test file should pass through _hash_file at most once per run.
|
||||
"""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
# Prime with one hit so we exercise the file-level (not directory-level) miss path.
|
||||
_prime_cache(cache_path, tree, hits={alpha_one: 1})
|
||||
|
||||
real_hash = split_tests._hash_file
|
||||
counts: dict[Path, int] = {}
|
||||
|
||||
def counting_hash(path: Path) -> str:
|
||||
counts[path] = counts.get(path, 0) + 1
|
||||
return real_hash(path)
|
||||
|
||||
# Pin the threshold so the tiny tree stays on the file-level path.
|
||||
with (
|
||||
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
|
||||
patch.object(split_tests, "_hash_file", side_effect=counting_hash),
|
||||
patch.object(
|
||||
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
|
||||
),
|
||||
):
|
||||
split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
assert all(n == 1 for n in counts.values()), counts
|
||||
|
||||
|
||||
def test_collect_tests_warm_cache_skips_pytest(tree: Path) -> None:
|
||||
"""A warm cache with no diffs should skip the pytest subprocess entirely."""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
_prime_cache(cache_path, tree, hits={alpha_one: 1, alpha_two: 2, beta_x: 3})
|
||||
|
||||
with patch.object(split_tests, "_run_collect_batches") as run_batches:
|
||||
folder = split_tests.collect_tests(tree, cache_path)
|
||||
run_batches.assert_not_called()
|
||||
assert folder.total_tests == 6
|
||||
|
||||
|
||||
def test_collect_tests_cold_cache_collects_only_missing(tree: Path) -> None:
|
||||
"""A partial cache should only re-collect the files that changed."""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
|
||||
_prime_cache(cache_path, tree, hits={alpha_one: 1})
|
||||
|
||||
with (
|
||||
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
|
||||
patch.object(
|
||||
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
|
||||
) as run_batches,
|
||||
):
|
||||
folder = split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
assert run_batches.call_count == 1
|
||||
requested = set(run_batches.call_args.args[0])
|
||||
assert requested == {alpha_two, beta_x}
|
||||
assert folder.total_tests == 3
|
||||
|
||||
# Cache should now contain entries for every test file.
|
||||
saved = json.loads(cache_path.read_text())
|
||||
assert set(saved["files"]) == {
|
||||
str(alpha_one.relative_to(tree)),
|
||||
str(alpha_two.relative_to(tree)),
|
||||
str(beta_x.relative_to(tree)),
|
||||
}
|
||||
|
||||
|
||||
def test_collect_tests_falls_back_to_dirs_when_misses_dominate(tree: Path) -> None:
|
||||
"""Heavy misses should switch back to dir-level invocation."""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
_prime_cache(cache_path, tree, hits={alpha_one: 1})
|
||||
# 2 misses / 3 total = 67% miss, above the 30% default threshold; this
|
||||
# also covers the new-directory PR case (mostly-new test files).
|
||||
|
||||
with patch.object(
|
||||
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
|
||||
) as run_batches:
|
||||
split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
# We expect the dir-level batch paths, not the individual miss files.
|
||||
requested = set(run_batches.call_args.args[0])
|
||||
assert requested == set(split_tests._enumerate_batch_paths(tree))
|
||||
|
||||
|
||||
def test_collect_tests_caches_files_with_no_collected_tests(tree: Path) -> None:
|
||||
"""Files pytest returns nothing for are cached as 0 so we stop re-collecting them.
|
||||
|
||||
Helper modules named test_*.py with no actual test functions look like
|
||||
test files to the walker but pytest reports no tests for them. We
|
||||
want the cache to remember that and skip them on subsequent runs.
|
||||
"""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
|
||||
# Prime the cache with one hit so collect_tests takes the file-level
|
||||
# diff path; the cold-cache path hands pytest top-level directories
|
||||
# rather than individual file paths.
|
||||
_prime_cache(cache_path, tree, hits={alpha_one: 1})
|
||||
|
||||
with (
|
||||
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
|
||||
patch.object(
|
||||
split_tests,
|
||||
"_run_collect_batches",
|
||||
side_effect=_echo_one_test_each(skip={alpha_two}),
|
||||
),
|
||||
):
|
||||
split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
saved = json.loads(cache_path.read_text())
|
||||
assert saved["files"][str(alpha_two.relative_to(tree))]["count"] == 0
|
||||
assert saved["files"][str(alpha_one.relative_to(tree))]["count"] == 1
|
||||
assert saved["files"][str(beta_x.relative_to(tree))]["count"] == 1
|
||||
|
||||
# Re-running with the same content should now be a full cache hit
|
||||
# even though alpha_two has no tests.
|
||||
with patch.object(split_tests, "_run_collect_batches") as run_batches:
|
||||
folder = split_tests.collect_tests(tree, cache_path)
|
||||
run_batches.assert_not_called()
|
||||
# alpha_two contributes 0, only alpha_one + beta_x count.
|
||||
assert folder.total_tests == 2
|
||||
|
||||
|
||||
def test_collect_tests_drops_deleted_files_from_cache(tree: Path) -> None:
|
||||
"""Files that disappear from disk are dropped from the saved cache."""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
ghost_rel = "components/alpha/test_ghost.py"
|
||||
|
||||
_prime_cache(
|
||||
cache_path,
|
||||
tree,
|
||||
hits={alpha_one: 1},
|
||||
extra_entries={
|
||||
ghost_rel: split_tests._CacheEntry(
|
||||
hash="dead", fixture_hash="dead", count=42
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
|
||||
patch.object(
|
||||
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
|
||||
),
|
||||
):
|
||||
split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
saved = json.loads(cache_path.read_text())
|
||||
assert ghost_rel not in saved["files"]
|
||||
|
||||
|
||||
def _build_folder(tree: Path, counts: dict[Path, int]) -> split_tests.TestFolder:
|
||||
"""Build a TestFolder for ``tree`` populated with ``counts``."""
|
||||
folder = split_tests.TestFolder(tree)
|
||||
for path, n in counts.items():
|
||||
folder.add_test_file(split_tests.TestFile(n, path))
|
||||
return folder
|
||||
|
||||
|
||||
def test_split_tests_keeps_siblings_together_when_snapshots_present(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Same-dir files stay together when the folder has syrupy snapshots."""
|
||||
one = tmp_path / "alpha" / "test_one.py"
|
||||
two = tmp_path / "alpha" / "test_two.py"
|
||||
one.parent.mkdir(parents=True)
|
||||
one.touch()
|
||||
two.touch()
|
||||
# Add a snapshot so the syrupy constraint kicks in.
|
||||
snapshots = tmp_path / "alpha" / "snapshots"
|
||||
snapshots.mkdir()
|
||||
(snapshots / "test_one.ambr").write_text("")
|
||||
|
||||
folder = _build_folder(tmp_path, {one: 60, two: 60})
|
||||
holder = split_tests.BucketHolder(tests_per_bucket=50, bucket_count=3)
|
||||
holder.split_tests(folder)
|
||||
# Both files must end up in one bucket; the other two stay empty.
|
||||
sizes = sorted(b.total_tests for b in holder._buckets)
|
||||
assert sizes == [0, 0, 120]
|
||||
|
||||
|
||||
def test_split_tests_splits_siblings_when_no_snapshots(tmp_path: Path) -> None:
|
||||
"""Same-dir files split freely across buckets when no snapshots exist."""
|
||||
one = tmp_path / "alpha" / "test_one.py"
|
||||
two = tmp_path / "alpha" / "test_two.py"
|
||||
one.parent.mkdir(parents=True)
|
||||
one.touch()
|
||||
two.touch()
|
||||
# No snapshots dir → free to split.
|
||||
|
||||
folder = _build_folder(tmp_path, {one: 60, two: 60})
|
||||
holder = split_tests.BucketHolder(tests_per_bucket=70, bucket_count=2)
|
||||
holder.split_tests(folder)
|
||||
sizes = sorted(b.total_tests for b in holder._buckets)
|
||||
assert sizes == [60, 60]
|
||||
Reference in New Issue
Block a user