forked from home-assistant/core
Compare commits
27 Commits
2021.7.0b3
...
2021.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933e016150 | ||
|
|
342366750b | ||
|
|
a048809ca7 | ||
|
|
f7c844d728 | ||
|
|
998ffeb21d | ||
|
|
a794c09a0f | ||
|
|
a7ee86730c | ||
|
|
dd26bfb92b | ||
|
|
b14b284e62 | ||
|
|
bad2525a6d | ||
|
|
7a503a6c1f | ||
|
|
40d9541d9b | ||
|
|
746a52bb27 | ||
|
|
90f4b3a4ed | ||
|
|
2c75e3fe99 | ||
|
|
e1c14b5a30 | ||
|
|
631e555e25 | ||
|
|
422de2c56d | ||
|
|
2356c1e52a | ||
|
|
2220c8cd3f | ||
|
|
979d37dc19 | ||
|
|
701fa06584 | ||
|
|
1c9053fef6 | ||
|
|
777cf116aa | ||
|
|
a52b4b0f62 | ||
|
|
dfce89f2c7 | ||
|
|
0cd097cd12 |
@@ -691,8 +691,7 @@ omit =
|
||||
homeassistant/components/niko_home_control/light.py
|
||||
homeassistant/components/nilu/air_quality.py
|
||||
homeassistant/components/nissan_leaf/*
|
||||
homeassistant/components/nmap_tracker/__init__.py
|
||||
homeassistant/components/nmap_tracker/device_tracker.py
|
||||
homeassistant/components/nmap_tracker/*
|
||||
homeassistant/components/nmbs/sensor.py
|
||||
homeassistant/components/notion/__init__.py
|
||||
homeassistant/components/notion/binary_sensor.py
|
||||
|
||||
@@ -332,7 +332,6 @@ homeassistant/components/nextcloud/* @meichthys
|
||||
homeassistant/components/nightscout/* @marciogranzotto
|
||||
homeassistant/components/nilu/* @hfurubotten
|
||||
homeassistant/components/nissan_leaf/* @filcole
|
||||
homeassistant/components/nmap_tracker/* @bdraco
|
||||
homeassistant/components/nmbs/* @thibmaek
|
||||
homeassistant/components/no_ip/* @fabaff
|
||||
homeassistant/components/noaa_tides/* @jdelaney72
|
||||
|
||||
@@ -146,7 +146,6 @@ class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC):
|
||||
self._attr_assumed_state = True
|
||||
self._attr_device_class = DEVICE_CLASS_SWITCH
|
||||
self._attr_name = f"{self._device.name} Switch"
|
||||
self._attr_unique_id = self._device.unique_id
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
@@ -215,6 +214,7 @@ class BroadlinkSP1Switch(BroadlinkSwitch):
|
||||
def __init__(self, device):
|
||||
"""Initialize the switch."""
|
||||
super().__init__(device, 1, 0)
|
||||
self._attr_unique_id = self._device.unique_id
|
||||
|
||||
async def _async_send_packet(self, packet):
|
||||
"""Send a packet to the device."""
|
||||
|
||||
@@ -19,50 +19,247 @@ API_ACCOUNTS_DATA = "data"
|
||||
API_RATES = "rates"
|
||||
|
||||
WALLETS = {
|
||||
"1INCH": "1INCH",
|
||||
"AAVE": "AAVE",
|
||||
"ADA": "ADA",
|
||||
"AED": "AED",
|
||||
"AFN": "AFN",
|
||||
"ALGO": "ALGO",
|
||||
"ALL": "ALL",
|
||||
"AMD": "AMD",
|
||||
"AMP": "AMP",
|
||||
"ANG": "ANG",
|
||||
"ANKR": "ANKR",
|
||||
"AOA": "AOA",
|
||||
"ARS": "ARS",
|
||||
"ATOM": "ATOM",
|
||||
"AUD": "AUD",
|
||||
"AWG": "AWG",
|
||||
"AZN": "AZN",
|
||||
"BAL": "BAL",
|
||||
"BAM": "BAM",
|
||||
"BAND": "BAND",
|
||||
"BAT": "BAT",
|
||||
"BBD": "BBD",
|
||||
"BCH": "BCH",
|
||||
"BDT": "BDT",
|
||||
"BGN": "BGN",
|
||||
"BHD": "BHD",
|
||||
"BIF": "BIF",
|
||||
"BMD": "BMD",
|
||||
"BND": "BND",
|
||||
"BNT": "BNT",
|
||||
"BOB": "BOB",
|
||||
"BOND": "BOND",
|
||||
"BRL": "BRL",
|
||||
"BSD": "BSD",
|
||||
"BSV": "BSV",
|
||||
"BTC": "BTC",
|
||||
"CGLD": "CLGD",
|
||||
"CVC": "CVC",
|
||||
"BTN": "BTN",
|
||||
"BWP": "BWP",
|
||||
"BYN": "BYN",
|
||||
"BYR": "BYR",
|
||||
"BZD": "BZD",
|
||||
"CAD": "CAD",
|
||||
"CDF": "CDF",
|
||||
"CGLD": "CGLD",
|
||||
"CHF": "CHF",
|
||||
"CHZ": "CHZ",
|
||||
"CLF": "CLF",
|
||||
"CLP": "CLP",
|
||||
"CNH": "CNH",
|
||||
"CNY": "CNY",
|
||||
"COMP": "COMP",
|
||||
"COP": "COP",
|
||||
"CRC": "CRC",
|
||||
"CRV": "CRV",
|
||||
"CTSI": "CTSI",
|
||||
"CUC": "CUC",
|
||||
"CVC": "CVC",
|
||||
"CVE": "CVE",
|
||||
"CZK": "CZK",
|
||||
"DAI": "DAI",
|
||||
"DASH": "DASH",
|
||||
"DJF": "DJF",
|
||||
"DKK": "DKK",
|
||||
"DNT": "DNT",
|
||||
"DOGE": "DOGE",
|
||||
"DOP": "DOP",
|
||||
"DOT": "DOT",
|
||||
"DZD": "DZD",
|
||||
"EGP": "EGP",
|
||||
"ENJ": "ENJ",
|
||||
"EOS": "EOS",
|
||||
"ERN": "ERN",
|
||||
"ETB": "ETB",
|
||||
"ETC": "ETC",
|
||||
"ETH": "ETH",
|
||||
"ETH2": "ETH2",
|
||||
"EUR": "EUR",
|
||||
"FIL": "FIL",
|
||||
"FJD": "FJD",
|
||||
"FKP": "FKP",
|
||||
"FORTH": "FORTH",
|
||||
"GBP": "GBP",
|
||||
"GBX": "GBX",
|
||||
"GEL": "GEL",
|
||||
"GGP": "GGP",
|
||||
"GHS": "GHS",
|
||||
"GIP": "GIP",
|
||||
"GMD": "GMD",
|
||||
"GNF": "GNF",
|
||||
"GRT": "GRT",
|
||||
"GTC": "GTC",
|
||||
"GTQ": "GTQ",
|
||||
"GYD": "GYD",
|
||||
"HKD": "HKD",
|
||||
"HNL": "HNL",
|
||||
"HRK": "HRK",
|
||||
"HTG": "HTG",
|
||||
"HUF": "HUF",
|
||||
"ICP": "ICP",
|
||||
"IDR": "IDR",
|
||||
"ILS": "ILS",
|
||||
"IMP": "IMP",
|
||||
"INR": "INR",
|
||||
"IQD": "IQD",
|
||||
"ISK": "ISK",
|
||||
"JEP": "JEP",
|
||||
"JMD": "JMD",
|
||||
"JOD": "JOD",
|
||||
"JPY": "JPY",
|
||||
"KEEP": "KEEP",
|
||||
"KES": "KES",
|
||||
"KGS": "KGS",
|
||||
"KHR": "KHR",
|
||||
"KMF": "KMF",
|
||||
"KNC": "KNC",
|
||||
"KRW": "KRW",
|
||||
"KWD": "KWD",
|
||||
"KYD": "KYD",
|
||||
"KZT": "KZT",
|
||||
"LAK": "LAK",
|
||||
"LBP": "LBP",
|
||||
"LINK": "LINK",
|
||||
"LKR": "LKR",
|
||||
"LPT": "LPT",
|
||||
"LRC": "LRC",
|
||||
"LRD": "LRD",
|
||||
"LSL": "LSL",
|
||||
"LTC": "LTC",
|
||||
"LYD": "LYD",
|
||||
"MAD": "MAD",
|
||||
"MANA": "MANA",
|
||||
"MATIC": "MATIC",
|
||||
"MDL": "MDL",
|
||||
"MGA": "MGA",
|
||||
"MIR": "MIR",
|
||||
"MKD": "MKD",
|
||||
"MKR": "MKR",
|
||||
"MLN": "MLN",
|
||||
"MMK": "MMK",
|
||||
"MNT": "MNT",
|
||||
"MOP": "MOP",
|
||||
"MRO": "MRO",
|
||||
"MTL": "MTL",
|
||||
"MUR": "MUR",
|
||||
"MVR": "MVR",
|
||||
"MWK": "MWK",
|
||||
"MXN": "MXN",
|
||||
"MYR": "MYR",
|
||||
"MZN": "MZN",
|
||||
"NAD": "NAD",
|
||||
"NGN": "NGN",
|
||||
"NIO": "NIO",
|
||||
"NKN": "NKN",
|
||||
"NMR": "NMR",
|
||||
"NOK": "NOK",
|
||||
"NPR": "NPR",
|
||||
"NU": "NU",
|
||||
"NZD": "NZD",
|
||||
"OGN": "OGN",
|
||||
"OMG": "OMG",
|
||||
"OMR": "OMR",
|
||||
"OXT": "OXT",
|
||||
"PAB": "PAB",
|
||||
"PEN": "PEN",
|
||||
"PGK": "PGK",
|
||||
"PHP": "PHP",
|
||||
"PKR": "PKR",
|
||||
"PLN": "PLN",
|
||||
"PYG": "PYG",
|
||||
"QAR": "QAR",
|
||||
"QNT": "QNT",
|
||||
"REN": "REN",
|
||||
"REP": "REP",
|
||||
"REPV2": "REPV2",
|
||||
"RLC": "RLC",
|
||||
"RON": "RON",
|
||||
"RSD": "RSD",
|
||||
"RUB": "RUB",
|
||||
"RWF": "RWF",
|
||||
"SAR": "SAR",
|
||||
"SBD": "SBD",
|
||||
"SCR": "SCR",
|
||||
"SEK": "SEK",
|
||||
"SGD": "SGD",
|
||||
"SHP": "SHP",
|
||||
"SKL": "SKL",
|
||||
"SLL": "SLL",
|
||||
"SNX": "SNX",
|
||||
"SOL": "SOL",
|
||||
"SOS": "SOS",
|
||||
"SRD": "SRD",
|
||||
"SSP": "SSP",
|
||||
"STD": "STD",
|
||||
"STORJ": "STORJ",
|
||||
"SUSHI": "SUSHI",
|
||||
"SVC": "SVC",
|
||||
"SZL": "SZL",
|
||||
"THB": "THB",
|
||||
"TJS": "TJS",
|
||||
"TMM": "TMM",
|
||||
"TMT": "TMT",
|
||||
"TND": "TND",
|
||||
"TOP": "TOP",
|
||||
"TRB": "TRB",
|
||||
"TRY": "TRY",
|
||||
"TTD": "TTD",
|
||||
"TWD": "TWD",
|
||||
"TZS": "TZS",
|
||||
"UAH": "UAH",
|
||||
"UGX": "UGX",
|
||||
"UMA": "UMA",
|
||||
"UNI": "UNI",
|
||||
"USD": "USD",
|
||||
"USDC": "USDC",
|
||||
"USDT": "USDT",
|
||||
"UYU": "UYU",
|
||||
"UZS": "UZS",
|
||||
"VES": "VES",
|
||||
"VND": "VND",
|
||||
"VUV": "VUV",
|
||||
"WBTC": "WBTC",
|
||||
"WST": "WST",
|
||||
"XAF": "XAF",
|
||||
"XAG": "XAG",
|
||||
"XAU": "XAU",
|
||||
"XCD": "XCD",
|
||||
"XDR": "XDR",
|
||||
"XLM": "XLM",
|
||||
"XOF": "XOF",
|
||||
"XPD": "XPD",
|
||||
"XPF": "XPF",
|
||||
"XPT": "XPT",
|
||||
"XRP": "XRP",
|
||||
"XTZ": "XTZ",
|
||||
"YER": "YER",
|
||||
"YFI": "YFI",
|
||||
"ZAR": "ZAR",
|
||||
"ZEC": "ZEC",
|
||||
"ZMW": "ZMW",
|
||||
"ZRX": "ZRX",
|
||||
"ZWL": "ZWL",
|
||||
}
|
||||
|
||||
RATES = {
|
||||
|
||||
@@ -20,8 +20,7 @@ async def async_setup_entry(hass, config_entry):
|
||||
Load config, group, light and sensor data for server information.
|
||||
Start websocket for push notification of state changes from deCONZ.
|
||||
"""
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
await async_update_group_unique_id(hass, config_entry)
|
||||
|
||||
@@ -33,7 +32,7 @@ async def async_setup_entry(hass, config_entry):
|
||||
if not await gateway.async_setup():
|
||||
return False
|
||||
|
||||
hass.data[DOMAIN][config_entry.unique_id] = gateway
|
||||
hass.data[DOMAIN][config_entry.entry_id] = gateway
|
||||
|
||||
await gateway.async_update_device_registry()
|
||||
|
||||
@@ -48,7 +47,7 @@ async def async_setup_entry(hass, config_entry):
|
||||
|
||||
async def async_unload_entry(hass, config_entry):
|
||||
"""Unload deCONZ config entry."""
|
||||
gateway = hass.data[DOMAIN].pop(config_entry.unique_id)
|
||||
gateway = hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
|
||||
if not hass.data[DOMAIN]:
|
||||
await async_unload_services(hass)
|
||||
|
||||
@@ -33,8 +33,8 @@ from .errors import AuthenticationRequired, CannotConnect
|
||||
|
||||
@callback
|
||||
def get_gateway_from_config_entry(hass, config_entry):
|
||||
"""Return gateway with a matching bridge id."""
|
||||
return hass.data[DECONZ_DOMAIN][config_entry.unique_id]
|
||||
"""Return gateway with a matching config entry ID."""
|
||||
return hass.data[DECONZ_DOMAIN][config_entry.entry_id]
|
||||
|
||||
|
||||
class DeconzGateway:
|
||||
|
||||
@@ -59,14 +59,29 @@ async def async_setup_services(hass):
|
||||
service = service_call.service
|
||||
service_data = service_call.data
|
||||
|
||||
gateway = get_master_gateway(hass)
|
||||
if CONF_BRIDGE_ID in service_data:
|
||||
found_gateway = False
|
||||
bridge_id = normalize_bridge_id(service_data[CONF_BRIDGE_ID])
|
||||
|
||||
for possible_gateway in hass.data[DOMAIN].values():
|
||||
if possible_gateway.bridgeid == bridge_id:
|
||||
gateway = possible_gateway
|
||||
found_gateway = True
|
||||
break
|
||||
|
||||
if not found_gateway:
|
||||
LOGGER.error("Could not find the gateway %s", bridge_id)
|
||||
return
|
||||
|
||||
if service == SERVICE_CONFIGURE_DEVICE:
|
||||
await async_configure_service(hass, service_data)
|
||||
await async_configure_service(gateway, service_data)
|
||||
|
||||
elif service == SERVICE_DEVICE_REFRESH:
|
||||
await async_refresh_devices_service(hass, service_data)
|
||||
await async_refresh_devices_service(gateway)
|
||||
|
||||
elif service == SERVICE_REMOVE_ORPHANED_ENTRIES:
|
||||
await async_remove_orphaned_entries_service(hass, service_data)
|
||||
await async_remove_orphaned_entries_service(gateway)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
@@ -102,7 +117,7 @@ async def async_unload_services(hass):
|
||||
hass.services.async_remove(DOMAIN, SERVICE_REMOVE_ORPHANED_ENTRIES)
|
||||
|
||||
|
||||
async def async_configure_service(hass, data):
|
||||
async def async_configure_service(gateway, data):
|
||||
"""Set attribute of device in deCONZ.
|
||||
|
||||
Entity is used to resolve to a device path (e.g. '/lights/1').
|
||||
@@ -118,10 +133,6 @@ async def async_configure_service(hass, data):
|
||||
See Dresden Elektroniks REST API documentation for details:
|
||||
http://dresden-elektronik.github.io/deconz-rest-doc/rest/
|
||||
"""
|
||||
gateway = get_master_gateway(hass)
|
||||
if CONF_BRIDGE_ID in data:
|
||||
gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])]
|
||||
|
||||
field = data.get(SERVICE_FIELD, "")
|
||||
entity_id = data.get(SERVICE_ENTITY)
|
||||
data = data[SERVICE_DATA]
|
||||
@@ -136,31 +147,21 @@ async def async_configure_service(hass, data):
|
||||
await gateway.api.request("put", field, json=data)
|
||||
|
||||
|
||||
async def async_refresh_devices_service(hass, data):
|
||||
async def async_refresh_devices_service(gateway):
|
||||
"""Refresh available devices from deCONZ."""
|
||||
gateway = get_master_gateway(hass)
|
||||
if CONF_BRIDGE_ID in data:
|
||||
gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])]
|
||||
|
||||
gateway.ignore_state_updates = True
|
||||
await gateway.api.refresh_state()
|
||||
gateway.ignore_state_updates = False
|
||||
|
||||
gateway.async_add_device_callback(NEW_GROUP, force=True)
|
||||
gateway.async_add_device_callback(NEW_LIGHT, force=True)
|
||||
gateway.async_add_device_callback(NEW_SCENE, force=True)
|
||||
gateway.async_add_device_callback(NEW_SENSOR, force=True)
|
||||
for new_device_type in [NEW_GROUP, NEW_LIGHT, NEW_SCENE, NEW_SENSOR]:
|
||||
gateway.async_add_device_callback(new_device_type, force=True)
|
||||
|
||||
|
||||
async def async_remove_orphaned_entries_service(hass, data):
|
||||
async def async_remove_orphaned_entries_service(gateway):
|
||||
"""Remove orphaned deCONZ entries from device and entity registries."""
|
||||
gateway = get_master_gateway(hass)
|
||||
if CONF_BRIDGE_ID in data:
|
||||
gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])]
|
||||
|
||||
device_registry, entity_registry = await asyncio.gather(
|
||||
hass.helpers.device_registry.async_get_registry(),
|
||||
hass.helpers.entity_registry.async_get_registry(),
|
||||
gateway.hass.helpers.device_registry.async_get_registry(),
|
||||
gateway.hass.helpers.entity_registry.async_get_registry(),
|
||||
)
|
||||
|
||||
entity_entries = async_entries_for_config_entry(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "eight_sleep",
|
||||
"name": "Eight Sleep",
|
||||
"documentation": "https://www.home-assistant.io/integrations/eight_sleep",
|
||||
"requirements": ["pyeight==0.1.8"],
|
||||
"requirements": ["pyeight==0.1.9"],
|
||||
"codeowners": ["@mezz64"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ def wifi_entities_list(
|
||||
) -> list[FritzBoxWifiSwitch]:
|
||||
"""Get list of wifi entities."""
|
||||
_LOGGER.debug("Setting up %s switches", SWITCH_TYPE_WIFINETWORK)
|
||||
std_table = {"ac": "5Ghz", "n": "2.4Ghz"}
|
||||
std_table = {"ax": "Wifi6", "ac": "5Ghz", "n": "2.4Ghz"}
|
||||
networks: dict = {}
|
||||
for i in range(4):
|
||||
if not ("WLANConfiguration" + str(i)) in fritzbox_tools.connection.services:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": [
|
||||
"home-assistant-frontend==20210630.0"
|
||||
"home-assistant-frontend==20210707.0"
|
||||
],
|
||||
"dependencies": [
|
||||
"api",
|
||||
|
||||
@@ -236,9 +236,20 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
config_num = None
|
||||
|
||||
# Set unique-id and error out if it's already configured
|
||||
existing_entry = await self.async_set_unique_id(normalize_hkid(hkid))
|
||||
updated_ip_port = {
|
||||
"AccessoryIP": discovery_info["host"],
|
||||
"AccessoryPort": discovery_info["port"],
|
||||
}
|
||||
|
||||
# If the device is already paired and known to us we should monitor c#
|
||||
# (config_num) for changes. If it changes, we check for new entities
|
||||
if paired and hkid in self.hass.data.get(KNOWN_DEVICES, {}):
|
||||
if existing_entry:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
existing_entry, data={**existing_entry.data, **updated_ip_port}
|
||||
)
|
||||
conn = self.hass.data[KNOWN_DEVICES][hkid]
|
||||
# When we rediscover the device, let aiohomekit know
|
||||
# that the device is available and we should not wait
|
||||
@@ -262,8 +273,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
await self.hass.config_entries.async_remove(existing.entry_id)
|
||||
|
||||
# Set unique-id and error out if it's already configured
|
||||
await self.async_set_unique_id(normalize_hkid(hkid))
|
||||
self._abort_if_unique_id_configured()
|
||||
self._abort_if_unique_id_configured(updates=updated_ip_port)
|
||||
|
||||
self.context["hkid"] = hkid
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "HomeKit Controller",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"requirements": ["aiohomekit==0.4.1"],
|
||||
"requirements": ["aiohomekit==0.4.2"],
|
||||
"zeroconf": ["_hap._tcp.local."],
|
||||
"after_dependencies": ["zeroconf"],
|
||||
"codeowners": ["@Jc2k", "@bdraco"],
|
||||
|
||||
@@ -132,7 +132,6 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity):
|
||||
def _turn_on_rgb_and_w(self, hex_template: str, **kwargs: Any) -> None:
|
||||
"""Turn on RGB or RGBW child device."""
|
||||
assert self._hs
|
||||
assert self._white is not None
|
||||
rgb = list(color_util.color_hs_to_RGB(*self._hs))
|
||||
white = self._white
|
||||
hex_color = self._values.get(self.value_type)
|
||||
@@ -151,8 +150,10 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity):
|
||||
if hex_template == "%02x%02x%02x%02x":
|
||||
if new_white is not None:
|
||||
rgb.append(new_white)
|
||||
else:
|
||||
elif white is not None:
|
||||
rgb.append(white)
|
||||
else:
|
||||
rgb.append(0)
|
||||
hex_color = hex_template % tuple(rgb)
|
||||
if len(rgb) > 3:
|
||||
white = rgb.pop()
|
||||
|
||||
@@ -1,395 +1 @@
|
||||
"""The Nmap Tracker integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from getmac import get_mac_address
|
||||
from mac_vendor_lookup import AsyncMacLookup
|
||||
from nmap import PortScanner, PortScannerError
|
||||
|
||||
from homeassistant.components.device_tracker.const import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TRACK_NEW,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import CoreState, HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_HOME_INTERVAL,
|
||||
CONF_OPTIONS,
|
||||
DEFAULT_TRACK_NEW_DEVICES,
|
||||
DOMAIN,
|
||||
NMAP_TRACKED_DEVICES,
|
||||
PLATFORMS,
|
||||
TRACKER_SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
# Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n'
|
||||
NMAP_TRANSIENT_FAILURE = "Assertion failed: htn.toclock_running == true"
|
||||
MAX_SCAN_ATTEMPTS = 16
|
||||
OFFLINE_SCANS_TO_MARK_UNAVAILABLE = 3
|
||||
|
||||
|
||||
def short_hostname(hostname):
|
||||
"""Return the first part of the hostname."""
|
||||
if hostname is None:
|
||||
return None
|
||||
return hostname.split(".")[0]
|
||||
|
||||
|
||||
def human_readable_name(hostname, vendor, mac_address):
|
||||
"""Generate a human readable name."""
|
||||
if hostname:
|
||||
return short_hostname(hostname)
|
||||
if vendor:
|
||||
return f"{vendor} {mac_address[-8:]}"
|
||||
return f"Nmap Tracker {mac_address}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NmapDevice:
|
||||
"""Class for keeping track of an nmap tracked device."""
|
||||
|
||||
mac_address: str
|
||||
hostname: str
|
||||
name: str
|
||||
ipv4: str
|
||||
manufacturer: str
|
||||
reason: str
|
||||
last_update: datetime.datetime
|
||||
offline_scans: int
|
||||
|
||||
|
||||
class NmapTrackedDevices:
|
||||
"""Storage class for all nmap trackers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the data."""
|
||||
self.tracked: dict = {}
|
||||
self.ipv4_last_mac: dict = {}
|
||||
self.config_entry_owner: dict = {}
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Nmap Tracker from a config entry."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
devices = domain_data.setdefault(NMAP_TRACKED_DEVICES, NmapTrackedDevices())
|
||||
scanner = domain_data[entry.entry_id] = NmapDeviceScanner(hass, entry, devices)
|
||||
await scanner.async_setup()
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
_async_untrack_devices(hass, entry)
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
@callback
|
||||
def _async_untrack_devices(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Remove tracking for devices owned by this config entry."""
|
||||
devices = hass.data[DOMAIN][NMAP_TRACKED_DEVICES]
|
||||
remove_mac_addresses = [
|
||||
mac_address
|
||||
for mac_address, entry_id in devices.config_entry_owner.items()
|
||||
if entry_id == entry.entry_id
|
||||
]
|
||||
for mac_address in remove_mac_addresses:
|
||||
if device := devices.tracked.pop(mac_address, None):
|
||||
devices.ipv4_last_mac.pop(device.ipv4, None)
|
||||
del devices.config_entry_owner[mac_address]
|
||||
|
||||
|
||||
def signal_device_update(mac_address) -> str:
|
||||
"""Signal specific per nmap tracker entry to signal updates in device."""
|
||||
return f"{DOMAIN}-device-update-{mac_address}"
|
||||
|
||||
|
||||
class NmapDeviceScanner:
|
||||
"""This class scans for devices using nmap."""
|
||||
|
||||
def __init__(self, hass, entry, devices):
|
||||
"""Initialize the scanner."""
|
||||
self.devices = devices
|
||||
self.home_interval = None
|
||||
|
||||
self._hass = hass
|
||||
self._entry = entry
|
||||
|
||||
self._scan_lock = None
|
||||
self._stopping = False
|
||||
self._scanner = None
|
||||
|
||||
self._entry_id = entry.entry_id
|
||||
self._hosts = None
|
||||
self._options = None
|
||||
self._exclude = None
|
||||
self._scan_interval = None
|
||||
self._track_new_devices = None
|
||||
|
||||
self._known_mac_addresses = {}
|
||||
self._finished_first_scan = False
|
||||
self._last_results = []
|
||||
self._mac_vendor_lookup = None
|
||||
|
||||
async def async_setup(self):
|
||||
"""Set up the tracker."""
|
||||
config = self._entry.options
|
||||
self._track_new_devices = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES)
|
||||
self._scan_interval = timedelta(
|
||||
seconds=config.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL)
|
||||
)
|
||||
hosts_list = cv.ensure_list_csv(config[CONF_HOSTS])
|
||||
self._hosts = [host for host in hosts_list if host != ""]
|
||||
excludes_list = cv.ensure_list_csv(config[CONF_EXCLUDE])
|
||||
self._exclude = [exclude for exclude in excludes_list if exclude != ""]
|
||||
self._options = config[CONF_OPTIONS]
|
||||
self.home_interval = timedelta(
|
||||
minutes=cv.positive_int(config[CONF_HOME_INTERVAL])
|
||||
)
|
||||
self._scan_lock = asyncio.Lock()
|
||||
if self._hass.state == CoreState.running:
|
||||
await self._async_start_scanner()
|
||||
return
|
||||
|
||||
self._entry.async_on_unload(
|
||||
self._hass.bus.async_listen(
|
||||
EVENT_HOMEASSISTANT_STARTED, self._async_start_scanner
|
||||
)
|
||||
)
|
||||
registry = er.async_get(self._hass)
|
||||
self._known_mac_addresses = {
|
||||
entry.unique_id: entry.original_name
|
||||
for entry in registry.entities.values()
|
||||
if entry.config_entry_id == self._entry_id
|
||||
}
|
||||
|
||||
@property
|
||||
def signal_device_new(self) -> str:
|
||||
"""Signal specific per nmap tracker entry to signal new device."""
|
||||
return f"{DOMAIN}-device-new-{self._entry_id}"
|
||||
|
||||
@property
|
||||
def signal_device_missing(self) -> str:
|
||||
"""Signal specific per nmap tracker entry to signal a missing device."""
|
||||
return f"{DOMAIN}-device-missing-{self._entry_id}"
|
||||
|
||||
@callback
|
||||
def _async_get_vendor(self, mac_address):
|
||||
"""Lookup the vendor."""
|
||||
oui = self._mac_vendor_lookup.sanitise(mac_address)[:6]
|
||||
return self._mac_vendor_lookup.prefixes.get(oui)
|
||||
|
||||
@callback
|
||||
def _async_stop(self):
|
||||
"""Stop the scanner."""
|
||||
self._stopping = True
|
||||
|
||||
async def _async_start_scanner(self, *_):
|
||||
"""Start the scanner."""
|
||||
self._entry.async_on_unload(self._async_stop)
|
||||
self._entry.async_on_unload(
|
||||
async_track_time_interval(
|
||||
self._hass,
|
||||
self._async_scan_devices,
|
||||
self._scan_interval,
|
||||
)
|
||||
)
|
||||
self._mac_vendor_lookup = AsyncMacLookup()
|
||||
with contextlib.suppress((asyncio.TimeoutError, aiohttp.ClientError)):
|
||||
# We don't care of this fails since its only
|
||||
# improves the data when we don't have it from nmap
|
||||
await self._mac_vendor_lookup.load_vendors()
|
||||
self._hass.async_create_task(self._async_scan_devices())
|
||||
|
||||
def _build_options(self):
|
||||
"""Build the command line and strip out last results that do not need to be updated."""
|
||||
options = self._options
|
||||
if self.home_interval:
|
||||
boundary = dt_util.now() - self.home_interval
|
||||
last_results = [
|
||||
device for device in self._last_results if device.last_update > boundary
|
||||
]
|
||||
if last_results:
|
||||
exclude_hosts = self._exclude + [device.ipv4 for device in last_results]
|
||||
else:
|
||||
exclude_hosts = self._exclude
|
||||
else:
|
||||
last_results = []
|
||||
exclude_hosts = self._exclude
|
||||
if exclude_hosts:
|
||||
options += f" --exclude {','.join(exclude_hosts)}"
|
||||
# Report reason
|
||||
if "--reason" not in options:
|
||||
options += " --reason"
|
||||
# Report down hosts
|
||||
if "-v" not in options:
|
||||
options += " -v"
|
||||
self._last_results = last_results
|
||||
return options
|
||||
|
||||
async def _async_scan_devices(self, *_):
|
||||
"""Scan devices and dispatch."""
|
||||
if self._scan_lock.locked():
|
||||
_LOGGER.debug(
|
||||
"Nmap scanning is taking longer than the scheduled interval: %s",
|
||||
TRACKER_SCAN_INTERVAL,
|
||||
)
|
||||
return
|
||||
|
||||
async with self._scan_lock:
|
||||
try:
|
||||
await self._async_run_nmap_scan()
|
||||
except PortScannerError as ex:
|
||||
_LOGGER.error("Nmap scanning failed: %s", ex)
|
||||
|
||||
if not self._finished_first_scan:
|
||||
self._finished_first_scan = True
|
||||
await self._async_mark_missing_devices_as_not_home()
|
||||
|
||||
async def _async_mark_missing_devices_as_not_home(self):
|
||||
# After all config entries have finished their first
|
||||
# scan we mark devices that were not found as not_home
|
||||
# from unavailable
|
||||
now = dt_util.now()
|
||||
for mac_address, original_name in self._known_mac_addresses.items():
|
||||
if mac_address in self.devices.tracked:
|
||||
continue
|
||||
self.devices.config_entry_owner[mac_address] = self._entry_id
|
||||
self.devices.tracked[mac_address] = NmapDevice(
|
||||
mac_address,
|
||||
None,
|
||||
original_name,
|
||||
None,
|
||||
self._async_get_vendor(mac_address),
|
||||
"Device not found in initial scan",
|
||||
now,
|
||||
1,
|
||||
)
|
||||
async_dispatcher_send(self._hass, self.signal_device_missing, mac_address)
|
||||
|
||||
def _run_nmap_scan(self):
|
||||
"""Run nmap and return the result."""
|
||||
options = self._build_options()
|
||||
if not self._scanner:
|
||||
self._scanner = PortScanner()
|
||||
_LOGGER.debug("Scanning %s with args: %s", self._hosts, options)
|
||||
for attempt in range(MAX_SCAN_ATTEMPTS):
|
||||
try:
|
||||
result = self._scanner.scan(
|
||||
hosts=" ".join(self._hosts),
|
||||
arguments=options,
|
||||
timeout=TRACKER_SCAN_INTERVAL * 10,
|
||||
)
|
||||
break
|
||||
except PortScannerError as ex:
|
||||
if attempt < (MAX_SCAN_ATTEMPTS - 1) and NMAP_TRANSIENT_FAILURE in str(
|
||||
ex
|
||||
):
|
||||
_LOGGER.debug("Nmap saw transient error %s", NMAP_TRANSIENT_FAILURE)
|
||||
continue
|
||||
raise
|
||||
_LOGGER.debug(
|
||||
"Finished scanning %s with args: %s",
|
||||
self._hosts,
|
||||
options,
|
||||
)
|
||||
return result
|
||||
|
||||
@callback
|
||||
def _async_increment_device_offline(self, ipv4, reason):
|
||||
"""Mark an IP offline."""
|
||||
if not (formatted_mac := self.devices.ipv4_last_mac.get(ipv4)):
|
||||
return
|
||||
if not (device := self.devices.tracked.get(formatted_mac)):
|
||||
# Device was unloaded
|
||||
return
|
||||
device.offline_scans += 1
|
||||
if device.offline_scans < OFFLINE_SCANS_TO_MARK_UNAVAILABLE:
|
||||
return
|
||||
device.reason = reason
|
||||
async_dispatcher_send(self._hass, signal_device_update(formatted_mac), False)
|
||||
del self.devices.ipv4_last_mac[ipv4]
|
||||
|
||||
async def _async_run_nmap_scan(self):
|
||||
"""Scan the network for devices and dispatch events."""
|
||||
result = await self._hass.async_add_executor_job(self._run_nmap_scan)
|
||||
if self._stopping:
|
||||
return
|
||||
|
||||
devices = self.devices
|
||||
entry_id = self._entry_id
|
||||
now = dt_util.now()
|
||||
for ipv4, info in result["scan"].items():
|
||||
status = info["status"]
|
||||
reason = status["reason"]
|
||||
if status["state"] != "up":
|
||||
self._async_increment_device_offline(ipv4, reason)
|
||||
continue
|
||||
# Mac address only returned if nmap ran as root
|
||||
mac = info["addresses"].get("mac") or get_mac_address(ip=ipv4)
|
||||
if mac is None:
|
||||
self._async_increment_device_offline(ipv4, "No MAC address found")
|
||||
_LOGGER.info("No MAC address found for %s", ipv4)
|
||||
continue
|
||||
|
||||
formatted_mac = format_mac(mac)
|
||||
new = formatted_mac not in devices.tracked
|
||||
if (
|
||||
new
|
||||
and not self._track_new_devices
|
||||
and formatted_mac not in devices.tracked
|
||||
and formatted_mac not in self._known_mac_addresses
|
||||
):
|
||||
continue
|
||||
|
||||
if (
|
||||
devices.config_entry_owner.setdefault(formatted_mac, entry_id)
|
||||
!= entry_id
|
||||
):
|
||||
continue
|
||||
|
||||
hostname = info["hostnames"][0]["name"] if info["hostnames"] else ipv4
|
||||
vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac)
|
||||
name = human_readable_name(hostname, vendor, mac)
|
||||
device = NmapDevice(
|
||||
formatted_mac, hostname, name, ipv4, vendor, reason, now, 0
|
||||
)
|
||||
|
||||
devices.tracked[formatted_mac] = device
|
||||
devices.ipv4_last_mac[ipv4] = formatted_mac
|
||||
self._last_results.append(device)
|
||||
|
||||
if new:
|
||||
async_dispatcher_send(self._hass, self.signal_device_new, formatted_mac)
|
||||
else:
|
||||
async_dispatcher_send(
|
||||
self._hass, signal_device_update(formatted_mac), True
|
||||
)
|
||||
"""The nmap_tracker component."""
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
"""Config flow for Nmap Tracker integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import ip_address, ip_network, summarize_address_range
|
||||
from typing import Any
|
||||
|
||||
import ifaddr
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.device_tracker.const import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TRACK_NEW,
|
||||
)
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util import get_local_ip
|
||||
|
||||
from .const import (
|
||||
CONF_HOME_INTERVAL,
|
||||
CONF_OPTIONS,
|
||||
DEFAULT_OPTIONS,
|
||||
DEFAULT_TRACK_NEW_DEVICES,
|
||||
DOMAIN,
|
||||
TRACKER_SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
DEFAULT_NETWORK_PREFIX = 24
|
||||
|
||||
|
||||
def get_network():
|
||||
"""Search adapters for the network."""
|
||||
adapters = ifaddr.get_adapters()
|
||||
local_ip = get_local_ip()
|
||||
network_prefix = (
|
||||
get_ip_prefix_from_adapters(local_ip, adapters) or DEFAULT_NETWORK_PREFIX
|
||||
)
|
||||
return str(ip_network(f"{local_ip}/{network_prefix}", False))
|
||||
|
||||
|
||||
def get_ip_prefix_from_adapters(local_ip, adapters):
|
||||
"""Find the network prefix for an adapter."""
|
||||
for adapter in adapters:
|
||||
for ip_cfg in adapter.ips:
|
||||
if local_ip == ip_cfg.ip:
|
||||
return ip_cfg.network_prefix
|
||||
|
||||
|
||||
def _normalize_ips_and_network(hosts_str):
|
||||
"""Check if a list of hosts are all ips or ip networks."""
|
||||
|
||||
normalized_hosts = []
|
||||
hosts = [host for host in cv.ensure_list_csv(hosts_str) if host != ""]
|
||||
|
||||
for host in sorted(hosts):
|
||||
try:
|
||||
start, end = host.split("-", 1)
|
||||
if "." not in end:
|
||||
ip_1, ip_2, ip_3, _ = start.split(".", 3)
|
||||
end = ".".join([ip_1, ip_2, ip_3, end])
|
||||
summarize_address_range(ip_address(start), ip_address(end))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
normalized_hosts.append(host)
|
||||
continue
|
||||
|
||||
try:
|
||||
ip_addr = ip_address(host)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
normalized_hosts.append(str(ip_addr))
|
||||
continue
|
||||
|
||||
try:
|
||||
network = ip_network(host)
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
normalized_hosts.append(str(network))
|
||||
|
||||
return normalized_hosts
|
||||
|
||||
|
||||
def normalize_input(user_input):
|
||||
"""Validate hosts and exclude are valid."""
|
||||
errors = {}
|
||||
normalized_hosts = _normalize_ips_and_network(user_input[CONF_HOSTS])
|
||||
if not normalized_hosts:
|
||||
errors[CONF_HOSTS] = "invalid_hosts"
|
||||
else:
|
||||
user_input[CONF_HOSTS] = ",".join(normalized_hosts)
|
||||
|
||||
normalized_exclude = _normalize_ips_and_network(user_input[CONF_EXCLUDE])
|
||||
if normalized_exclude is None:
|
||||
errors[CONF_EXCLUDE] = "invalid_hosts"
|
||||
else:
|
||||
user_input[CONF_EXCLUDE] = ",".join(normalized_exclude)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
async def _async_build_schema_with_user_input(hass, user_input, include_options):
|
||||
hosts = user_input.get(CONF_HOSTS, await hass.async_add_executor_job(get_network))
|
||||
exclude = user_input.get(
|
||||
CONF_EXCLUDE, await hass.async_add_executor_job(get_local_ip)
|
||||
)
|
||||
schema = {
|
||||
vol.Required(CONF_HOSTS, default=hosts): str,
|
||||
vol.Required(
|
||||
CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0)
|
||||
): int,
|
||||
vol.Optional(CONF_EXCLUDE, default=exclude): str,
|
||||
vol.Optional(
|
||||
CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS)
|
||||
): str,
|
||||
}
|
||||
if include_options:
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_TRACK_NEW,
|
||||
default=user_input.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_SCAN_INTERVAL,
|
||||
default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)),
|
||||
}
|
||||
)
|
||||
return vol.Schema(schema)
|
||||
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle a option flow for homekit."""
|
||||
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.options = dict(config_entry.options)
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle options flow."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
errors = normalize_input(user_input)
|
||||
self.options.update(user_input)
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=f"Nmap Tracker {self.options[CONF_HOSTS]}", data=self.options
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=await _async_build_schema_with_user_input(
|
||||
self.hass, self.options, True
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Nmap Tracker."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize config flow."""
|
||||
self.options = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
if not self._async_is_unique_host_list(user_input):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
errors = normalize_input(user_input)
|
||||
self.options.update(user_input)
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=f"Nmap Tracker {user_input[CONF_HOSTS]}",
|
||||
data={},
|
||||
options=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=await _async_build_schema_with_user_input(
|
||||
self.hass, self.options, False
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
def _async_is_unique_host_list(self, user_input):
|
||||
hosts = _normalize_ips_and_network(user_input[CONF_HOSTS])
|
||||
for entry in self._async_current_entries():
|
||||
if _normalize_ips_and_network(entry.options[CONF_HOSTS]) == hosts:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def async_step_import(self, user_input=None):
|
||||
"""Handle import from yaml."""
|
||||
if not self._async_is_unique_host_list(user_input):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
normalize_input(user_input)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"Nmap Tracker {user_input[CONF_HOSTS]}", data={}, options=user_input
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
@@ -1,40 +1,29 @@
|
||||
"""Support for scanning a network with nmap."""
|
||||
|
||||
from collections import namedtuple
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from getmac import get_mac_address
|
||||
from nmap import PortScanner, PortScannerError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import (
|
||||
DOMAIN as DEVICE_TRACKER_DOMAIN,
|
||||
PLATFORM_SCHEMA,
|
||||
SOURCE_TYPE_ROUTER,
|
||||
)
|
||||
from homeassistant.components.device_tracker.config_entry import ScannerEntity
|
||||
from homeassistant.components.device_tracker.const import (
|
||||
CONF_NEW_DEVICE_DEFAULTS,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TRACK_NEW,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from . import NmapDeviceScanner, short_hostname, signal_device_update
|
||||
from .const import (
|
||||
CONF_HOME_INTERVAL,
|
||||
CONF_OPTIONS,
|
||||
DEFAULT_OPTIONS,
|
||||
DEFAULT_TRACK_NEW_DEVICES,
|
||||
DOMAIN,
|
||||
TRACKER_SCAN_INTERVAL,
|
||||
PLATFORM_SCHEMA,
|
||||
DeviceScanner,
|
||||
)
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Interval in minutes to exclude devices from a scan while they are home
|
||||
CONF_HOME_INTERVAL = "home_interval"
|
||||
CONF_OPTIONS = "scan_options"
|
||||
DEFAULT_OPTIONS = "-F --host-timeout 5s"
|
||||
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_HOSTS): cv.ensure_list,
|
||||
@@ -45,164 +34,100 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
async def async_get_scanner(hass, config):
|
||||
def get_scanner(hass, config):
|
||||
"""Validate the configuration and return a Nmap scanner."""
|
||||
validated_config = config[DEVICE_TRACKER_DOMAIN]
|
||||
return NmapDeviceScanner(config[DOMAIN])
|
||||
|
||||
if CONF_SCAN_INTERVAL in validated_config:
|
||||
scan_interval = validated_config[CONF_SCAN_INTERVAL].total_seconds()
|
||||
else:
|
||||
scan_interval = TRACKER_SCAN_INTERVAL
|
||||
|
||||
import_config = {
|
||||
CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]),
|
||||
CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL],
|
||||
CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]),
|
||||
CONF_OPTIONS: validated_config[CONF_OPTIONS],
|
||||
CONF_SCAN_INTERVAL: scan_interval,
|
||||
CONF_TRACK_NEW: validated_config.get(CONF_NEW_DEVICE_DEFAULTS, {}).get(
|
||||
CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES
|
||||
),
|
||||
}
|
||||
Device = namedtuple("Device", ["mac", "name", "ip", "last_update"])
|
||||
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=import_config,
|
||||
|
||||
class NmapDeviceScanner(DeviceScanner):
|
||||
"""This class scans for devices using nmap."""
|
||||
|
||||
exclude = []
|
||||
|
||||
def __init__(self, config):
|
||||
"""Initialize the scanner."""
|
||||
self.last_results = []
|
||||
|
||||
self.hosts = config[CONF_HOSTS]
|
||||
self.exclude = config[CONF_EXCLUDE]
|
||||
minutes = config[CONF_HOME_INTERVAL]
|
||||
self._options = config[CONF_OPTIONS]
|
||||
self.home_interval = timedelta(minutes=minutes)
|
||||
|
||||
_LOGGER.debug("Scanner initialized")
|
||||
|
||||
def scan_devices(self):
|
||||
"""Scan for new devices and return a list with found device IDs."""
|
||||
self._update_info()
|
||||
|
||||
_LOGGER.debug("Nmap last results %s", self.last_results)
|
||||
|
||||
return [device.mac for device in self.last_results]
|
||||
|
||||
def get_device_name(self, device):
|
||||
"""Return the name of the given device or None if we don't know."""
|
||||
filter_named = [
|
||||
result.name for result in self.last_results if result.mac == device
|
||||
]
|
||||
|
||||
if filter_named:
|
||||
return filter_named[0]
|
||||
return None
|
||||
|
||||
def get_extra_attributes(self, device):
|
||||
"""Return the IP of the given device."""
|
||||
filter_ip = next(
|
||||
(result.ip for result in self.last_results if result.mac == device), None
|
||||
)
|
||||
)
|
||||
return {"ip": filter_ip}
|
||||
|
||||
_LOGGER.warning(
|
||||
"Your Nmap Tracker configuration has been imported into the UI, "
|
||||
"please remove it from configuration.yaml. "
|
||||
)
|
||||
def _update_info(self):
|
||||
"""Scan the network for devices.
|
||||
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
_LOGGER.debug("Scanning")
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable
|
||||
) -> None:
|
||||
"""Set up device tracker for Nmap Tracker component."""
|
||||
nmap_tracker = hass.data[DOMAIN][entry.entry_id]
|
||||
scanner = PortScanner()
|
||||
|
||||
@callback
|
||||
def device_new(mac_address):
|
||||
"""Signal a new device."""
|
||||
async_add_entities([NmapTrackerEntity(nmap_tracker, mac_address, True)])
|
||||
options = self._options
|
||||
|
||||
@callback
|
||||
def device_missing(mac_address):
|
||||
"""Signal a missing device."""
|
||||
async_add_entities([NmapTrackerEntity(nmap_tracker, mac_address, False)])
|
||||
if self.home_interval:
|
||||
boundary = dt_util.now() - self.home_interval
|
||||
last_results = [
|
||||
device for device in self.last_results if device.last_update > boundary
|
||||
]
|
||||
if last_results:
|
||||
exclude_hosts = self.exclude + [device.ip for device in last_results]
|
||||
else:
|
||||
exclude_hosts = self.exclude
|
||||
else:
|
||||
last_results = []
|
||||
exclude_hosts = self.exclude
|
||||
if exclude_hosts:
|
||||
options += f" --exclude {','.join(exclude_hosts)}"
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(hass, nmap_tracker.signal_device_new, device_new)
|
||||
)
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, nmap_tracker.signal_device_missing, device_missing
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = scanner.scan(hosts=" ".join(self.hosts), arguments=options)
|
||||
except PortScannerError:
|
||||
return False
|
||||
|
||||
now = dt_util.now()
|
||||
for ipv4, info in result["scan"].items():
|
||||
if info["status"]["state"] != "up":
|
||||
continue
|
||||
name = info["hostnames"][0]["name"] if info["hostnames"] else ipv4
|
||||
# Mac address only returned if nmap ran as root
|
||||
mac = info["addresses"].get("mac") or get_mac_address(ip=ipv4)
|
||||
if mac is None:
|
||||
_LOGGER.info("No MAC address found for %s", ipv4)
|
||||
continue
|
||||
last_results.append(Device(mac.upper(), name, ipv4, now))
|
||||
|
||||
class NmapTrackerEntity(ScannerEntity):
|
||||
"""An Nmap Tracker entity."""
|
||||
self.last_results = last_results
|
||||
|
||||
def __init__(
|
||||
self, nmap_tracker: NmapDeviceScanner, mac_address: str, active: bool
|
||||
) -> None:
|
||||
"""Initialize an nmap tracker entity."""
|
||||
self._mac_address = mac_address
|
||||
self._nmap_tracker = nmap_tracker
|
||||
self._tracked = self._nmap_tracker.devices.tracked
|
||||
self._active = active
|
||||
|
||||
@property
|
||||
def _device(self) -> bool:
|
||||
"""Get latest device state."""
|
||||
return self._tracked[self._mac_address]
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return device status."""
|
||||
return self._active
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return device name."""
|
||||
return self._device.name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return device unique id."""
|
||||
return self._mac_address
|
||||
|
||||
@property
|
||||
def ip_address(self) -> str:
|
||||
"""Return the primary ip address of the device."""
|
||||
return self._device.ipv4
|
||||
|
||||
@property
|
||||
def mac_address(self) -> str:
|
||||
"""Return the mac address of the device."""
|
||||
return self._mac_address
|
||||
|
||||
@property
|
||||
def hostname(self) -> str:
|
||||
"""Return hostname of the device."""
|
||||
return short_hostname(self._device.hostname)
|
||||
|
||||
@property
|
||||
def source_type(self) -> str:
|
||||
"""Return tracker source type."""
|
||||
return SOURCE_TYPE_ROUTER
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return the device information."""
|
||||
return {
|
||||
"connections": {(CONNECTION_NETWORK_MAC, self._mac_address)},
|
||||
"default_manufacturer": self._device.manufacturer,
|
||||
"default_name": self.name,
|
||||
}
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return device icon."""
|
||||
return "mdi:lan-connect" if self._active else "mdi:lan-disconnect"
|
||||
|
||||
@callback
|
||||
def async_process_update(self, online: bool) -> None:
|
||||
"""Update device."""
|
||||
self._active = online
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the attributes."""
|
||||
return {
|
||||
"last_time_reachable": self._device.last_update.isoformat(
|
||||
timespec="seconds"
|
||||
),
|
||||
"reason": self._device.reason,
|
||||
}
|
||||
|
||||
@callback
|
||||
def async_on_demand_update(self, online: bool):
|
||||
"""Update state."""
|
||||
self.async_process_update(online)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register state update callback."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
signal_device_update(self._mac_address),
|
||||
self.async_on_demand_update,
|
||||
)
|
||||
)
|
||||
_LOGGER.debug("nmap scan successful")
|
||||
return True
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
"domain": "nmap_tracker",
|
||||
"name": "Nmap Tracker",
|
||||
"documentation": "https://www.home-assistant.io/integrations/nmap_tracker",
|
||||
"requirements": [
|
||||
"netmap==0.7.0.2",
|
||||
"getmac==0.8.2",
|
||||
"ifaddr==0.1.7",
|
||||
"mac-vendor-lookup==0.1.11"
|
||||
],
|
||||
"codeowners": ["@bdraco"],
|
||||
"iot_class": "local_polling",
|
||||
"config_flow": true
|
||||
"requirements": ["python-nmap==0.6.1", "getmac==0.8.2"],
|
||||
"codeowners": [],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ from .const import DOMAIN as SENSIBO_DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ALL = ["all"]
|
||||
TIMEOUT = 10
|
||||
TIMEOUT = 8
|
||||
|
||||
SERVICE_ASSUME_STATE = "assume_state"
|
||||
|
||||
@@ -91,17 +91,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
|
||||
)
|
||||
devices = []
|
||||
try:
|
||||
for dev in await client.async_get_devices(_INITIAL_FETCH_FIELDS):
|
||||
if config[CONF_ID] == ALL or dev["id"] in config[CONF_ID]:
|
||||
devices.append(
|
||||
SensiboClimate(client, dev, hass.config.units.temperature_unit)
|
||||
)
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
for dev in await client.async_get_devices(_INITIAL_FETCH_FIELDS):
|
||||
if config[CONF_ID] == ALL or dev["id"] in config[CONF_ID]:
|
||||
devices.append(
|
||||
SensiboClimate(client, dev, hass.config.units.temperature_unit)
|
||||
)
|
||||
except (
|
||||
aiohttp.client_exceptions.ClientConnectorError,
|
||||
asyncio.TimeoutError,
|
||||
pysensibo.SensiboError,
|
||||
) as err:
|
||||
_LOGGER.exception("Failed to connect to Sensibo servers")
|
||||
_LOGGER.error("Failed to get devices from Sensibo servers")
|
||||
raise PlatformNotReady from err
|
||||
|
||||
if not devices:
|
||||
@@ -150,6 +151,7 @@ class SensiboClimate(ClimateEntity):
|
||||
self._units = units
|
||||
self._available = False
|
||||
self._do_update(data)
|
||||
self._failed_update = False
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
@@ -316,59 +318,35 @@ class SensiboClimate(ClimateEntity):
|
||||
else:
|
||||
return
|
||||
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
await self._client.async_set_ac_state_property(
|
||||
self._id, "targetTemperature", temperature, self._ac_states
|
||||
)
|
||||
await self._async_set_ac_state_property("targetTemperature", temperature)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode):
|
||||
"""Set new target fan mode."""
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
await self._client.async_set_ac_state_property(
|
||||
self._id, "fanLevel", fan_mode, self._ac_states
|
||||
)
|
||||
await self._async_set_ac_state_property("fanLevel", fan_mode)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode):
|
||||
"""Set new target operation mode."""
|
||||
if hvac_mode == HVAC_MODE_OFF:
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
await self._client.async_set_ac_state_property(
|
||||
self._id, "on", False, self._ac_states
|
||||
)
|
||||
await self._async_set_ac_state_property("on", False)
|
||||
return
|
||||
|
||||
# Turn on if not currently on.
|
||||
if not self._ac_states["on"]:
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
await self._client.async_set_ac_state_property(
|
||||
self._id, "on", True, self._ac_states
|
||||
)
|
||||
await self._async_set_ac_state_property("on", True)
|
||||
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
await self._client.async_set_ac_state_property(
|
||||
self._id, "mode", HA_TO_SENSIBO[hvac_mode], self._ac_states
|
||||
)
|
||||
await self._async_set_ac_state_property("mode", HA_TO_SENSIBO[hvac_mode])
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode):
|
||||
"""Set new target swing operation."""
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
await self._client.async_set_ac_state_property(
|
||||
self._id, "swing", swing_mode, self._ac_states
|
||||
)
|
||||
await self._async_set_ac_state_property("swing", swing_mode)
|
||||
|
||||
async def async_turn_on(self):
|
||||
"""Turn Sensibo unit on."""
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
await self._client.async_set_ac_state_property(
|
||||
self._id, "on", True, self._ac_states
|
||||
)
|
||||
await self._async_set_ac_state_property("on", True)
|
||||
|
||||
async def async_turn_off(self):
|
||||
"""Turn Sensibo unit on."""
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
await self._client.async_set_ac_state_property(
|
||||
self._id, "on", False, self._ac_states
|
||||
)
|
||||
await self._async_set_ac_state_property("on", False)
|
||||
|
||||
async def async_assume_state(self, state):
|
||||
"""Set external state."""
|
||||
@@ -377,14 +355,7 @@ class SensiboClimate(ClimateEntity):
|
||||
)
|
||||
|
||||
if change_needed:
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
await self._client.async_set_ac_state_property(
|
||||
self._id,
|
||||
"on",
|
||||
state != HVAC_MODE_OFF, # value
|
||||
self._ac_states,
|
||||
True, # assumed_state
|
||||
)
|
||||
await self._async_set_ac_state_property("on", state != HVAC_MODE_OFF, True)
|
||||
|
||||
if state in [STATE_ON, HVAC_MODE_OFF]:
|
||||
self._external_state = None
|
||||
@@ -396,7 +367,41 @@ class SensiboClimate(ClimateEntity):
|
||||
try:
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
data = await self._client.async_get_device(self._id, _FETCH_FIELDS)
|
||||
self._do_update(data)
|
||||
except (aiohttp.client_exceptions.ClientError, pysensibo.SensiboError):
|
||||
_LOGGER.warning("Failed to connect to Sensibo servers")
|
||||
except (
|
||||
aiohttp.client_exceptions.ClientError,
|
||||
asyncio.TimeoutError,
|
||||
pysensibo.SensiboError,
|
||||
):
|
||||
if self._failed_update:
|
||||
_LOGGER.warning(
|
||||
"Failed to update data for device '%s' from Sensibo servers",
|
||||
self.name,
|
||||
)
|
||||
self._available = False
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
_LOGGER.debug("First failed update data for device '%s'", self.name)
|
||||
self._failed_update = True
|
||||
return
|
||||
|
||||
self._failed_update = False
|
||||
self._do_update(data)
|
||||
|
||||
async def _async_set_ac_state_property(self, name, value, assumed_state=False):
|
||||
"""Set AC state."""
|
||||
try:
|
||||
with async_timeout.timeout(TIMEOUT):
|
||||
await self._client.async_set_ac_state_property(
|
||||
self._id, name, value, self._ac_states, assumed_state
|
||||
)
|
||||
except (
|
||||
aiohttp.client_exceptions.ClientError,
|
||||
asyncio.TimeoutError,
|
||||
pysensibo.SensiboError,
|
||||
) as err:
|
||||
self._available = False
|
||||
self.async_write_ha_state()
|
||||
raise Exception(
|
||||
f"Failed to set AC state for device {self.name} to Sensibo servers"
|
||||
) from err
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import asyncio
|
||||
from uuid import UUID
|
||||
|
||||
from simplipy import API
|
||||
from simplipy import get_api
|
||||
from simplipy.errors import EndpointUnavailable, InvalidCredentialsError, SimplipyError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_CODE, CONF_CODE, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.const import ATTR_CODE, CONF_CODE, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import CoreState, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import (
|
||||
@@ -107,14 +107,6 @@ SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = SERVICE_BASE_SCHEMA.extend(
|
||||
CONFIG_SCHEMA = cv.deprecated(DOMAIN)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_save_refresh_token(hass, config_entry, token):
|
||||
"""Save a refresh token to the config entry."""
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, data={**config_entry.data, CONF_TOKEN: token}
|
||||
)
|
||||
|
||||
|
||||
async def async_get_client_id(hass):
|
||||
"""Get a client ID (based on the HASS unique ID) for the SimpliSafe API.
|
||||
|
||||
@@ -142,6 +134,9 @@ async def async_setup_entry(hass, config_entry): # noqa: C901
|
||||
hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = []
|
||||
hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = []
|
||||
|
||||
if CONF_PASSWORD not in config_entry.data:
|
||||
raise ConfigEntryAuthFailed("Config schema change requires re-authentication")
|
||||
|
||||
entry_updates = {}
|
||||
if not config_entry.unique_id:
|
||||
# If the config entry doesn't already have a unique ID, set one:
|
||||
@@ -164,19 +159,19 @@ async def async_setup_entry(hass, config_entry): # noqa: C901
|
||||
websession = aiohttp_client.async_get_clientsession(hass)
|
||||
|
||||
try:
|
||||
api = await API.login_via_token(
|
||||
config_entry.data[CONF_TOKEN], client_id=client_id, session=websession
|
||||
api = await get_api(
|
||||
config_entry.data[CONF_USERNAME],
|
||||
config_entry.data[CONF_PASSWORD],
|
||||
client_id=client_id,
|
||||
session=websession,
|
||||
)
|
||||
except InvalidCredentialsError:
|
||||
LOGGER.error("Invalid credentials provided")
|
||||
return False
|
||||
except InvalidCredentialsError as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except SimplipyError as err:
|
||||
LOGGER.error("Config entry failed: %s", err)
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
_async_save_refresh_token(hass, config_entry, api.refresh_token)
|
||||
|
||||
simplisafe = SimpliSafe(hass, api, config_entry)
|
||||
simplisafe = SimpliSafe(hass, config_entry, api)
|
||||
|
||||
try:
|
||||
await simplisafe.async_init()
|
||||
@@ -303,10 +298,9 @@ async def async_reload_entry(hass, config_entry):
|
||||
class SimpliSafe:
|
||||
"""Define a SimpliSafe data object."""
|
||||
|
||||
def __init__(self, hass, api, config_entry):
|
||||
def __init__(self, hass, config_entry, api):
|
||||
"""Initialize."""
|
||||
self._api = api
|
||||
self._emergency_refresh_token_used = False
|
||||
self._hass = hass
|
||||
self._system_notifications = {}
|
||||
self.config_entry = config_entry
|
||||
@@ -383,23 +377,7 @@ class SimpliSafe:
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, InvalidCredentialsError):
|
||||
if self._emergency_refresh_token_used:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Update failed with stored refresh token"
|
||||
)
|
||||
|
||||
LOGGER.warning("SimpliSafe cloud error; trying stored refresh token")
|
||||
self._emergency_refresh_token_used = True
|
||||
|
||||
try:
|
||||
await self._api.refresh_access_token(
|
||||
self.config_entry.data[CONF_TOKEN]
|
||||
)
|
||||
return
|
||||
except SimplipyError as err:
|
||||
raise UpdateFailed( # pylint: disable=raise-missing-from
|
||||
f"Error while using stored refresh token: {err}"
|
||||
)
|
||||
raise ConfigEntryAuthFailed("Invalid credentials") from result
|
||||
|
||||
if isinstance(result, EndpointUnavailable):
|
||||
# In case the user attempts an action not allowed in their current plan,
|
||||
@@ -410,16 +388,6 @@ class SimpliSafe:
|
||||
if isinstance(result, SimplipyError):
|
||||
raise UpdateFailed(f"SimpliSafe error while updating: {result}")
|
||||
|
||||
if self._api.refresh_token != self.config_entry.data[CONF_TOKEN]:
|
||||
_async_save_refresh_token(
|
||||
self._hass, self.config_entry, self._api.refresh_token
|
||||
)
|
||||
|
||||
# If we've reached this point using an emergency refresh token, we're in the
|
||||
# clear and we can discard it:
|
||||
if self._emergency_refresh_token_used:
|
||||
self._emergency_refresh_token_used = False
|
||||
|
||||
|
||||
class SimpliSafeEntity(CoordinatorEntity):
|
||||
"""Define a base SimpliSafe entity."""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Config flow to configure the SimpliSafe component."""
|
||||
from simplipy import API
|
||||
from simplipy import get_api
|
||||
from simplipy.errors import (
|
||||
InvalidCredentialsError,
|
||||
PendingAuthorizationError,
|
||||
@@ -8,7 +8,7 @@ from simplipy.errors import (
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
@@ -47,7 +47,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
client_id = await async_get_client_id(self.hass)
|
||||
websession = aiohttp_client.async_get_clientsession(self.hass)
|
||||
|
||||
return await API.login_via_credentials(
|
||||
return await get_api(
|
||||
self._username,
|
||||
self._password,
|
||||
client_id=client_id,
|
||||
@@ -59,7 +59,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
simplisafe = await self._async_get_simplisafe_api()
|
||||
await self._async_get_simplisafe_api()
|
||||
except PendingAuthorizationError:
|
||||
LOGGER.info("Awaiting confirmation of MFA email click")
|
||||
return await self.async_step_mfa()
|
||||
@@ -79,7 +79,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_finish(
|
||||
{
|
||||
CONF_USERNAME: self._username,
|
||||
CONF_TOKEN: simplisafe.refresh_token,
|
||||
CONF_PASSWORD: self._password,
|
||||
CONF_CODE: self._code,
|
||||
}
|
||||
)
|
||||
@@ -89,6 +89,9 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
existing_entry = await self.async_set_unique_id(self._username)
|
||||
if existing_entry:
|
||||
self.hass.config_entries.async_update_entry(existing_entry, data=user_input)
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||
)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
return self.async_create_entry(title=self._username, data=user_input)
|
||||
|
||||
@@ -98,7 +101,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(step_id="mfa")
|
||||
|
||||
try:
|
||||
simplisafe = await self._async_get_simplisafe_api()
|
||||
await self._async_get_simplisafe_api()
|
||||
except PendingAuthorizationError:
|
||||
LOGGER.error("Still awaiting confirmation of MFA email click")
|
||||
return self.async_show_form(
|
||||
@@ -108,7 +111,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_finish(
|
||||
{
|
||||
CONF_USERNAME: self._username,
|
||||
CONF_TOKEN: simplisafe.refresh_token,
|
||||
CONF_PASSWORD: self._password,
|
||||
CONF_CODE: self._code,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "SimpliSafe",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/simplisafe",
|
||||
"requirements": ["simplisafe-python==10.0.0"],
|
||||
"requirements": ["simplisafe-python==11.0.0"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "Your access token has expired or been revoked. Enter your password to re-link your account.",
|
||||
"description": "Your access has expired or been revoked. Enter your password to re-link your account.",
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"data": {
|
||||
"password": "Password"
|
||||
},
|
||||
"description": "Your access token has expired or been revoked. Enter your password to re-link your account.",
|
||||
"description": "Your access has expired or been revoked. Enter your password to re-link your account.",
|
||||
"title": "Reauthenticate Integration"
|
||||
},
|
||||
"user": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "SMA Solar",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sma",
|
||||
"requirements": ["pysma==0.6.1"],
|
||||
"requirements": ["pysma==0.6.2"],
|
||||
"codeowners": ["@kellerza", "@rklomp"],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Support for Somfy hubs."""
|
||||
from abc import abstractmethod
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
@@ -8,20 +7,16 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_OPTIMISTIC
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from . import api, config_flow
|
||||
from .const import API, COORDINATOR, DOMAIN
|
||||
from .const import COORDINATOR, DOMAIN
|
||||
from .coordinator import SomfyDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -84,25 +79,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
data = hass.data[DOMAIN]
|
||||
data[API] = api.ConfigEntrySomfyApi(hass, entry, implementation)
|
||||
|
||||
async def _update_all_devices():
|
||||
"""Update all the devices."""
|
||||
devices = await hass.async_add_executor_job(data[API].get_devices)
|
||||
previous_devices = data[COORDINATOR].data
|
||||
# Sometimes Somfy returns an empty list.
|
||||
if not devices and previous_devices:
|
||||
_LOGGER.debug(
|
||||
"No devices returned. Assuming the previous ones are still valid"
|
||||
)
|
||||
return previous_devices
|
||||
return {dev.id: dev for dev in devices}
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
coordinator = SomfyDataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="somfy device update",
|
||||
update_method=_update_all_devices,
|
||||
client=api.ConfigEntrySomfyApi(hass, entry, implementation),
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
data[COORDINATOR] = coordinator
|
||||
@@ -140,70 +121,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Unload a config entry."""
|
||||
hass.data[DOMAIN].pop(API, None)
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
class SomfyEntity(CoordinatorEntity, Entity):
|
||||
"""Representation of a generic Somfy device."""
|
||||
|
||||
def __init__(self, coordinator, device_id, somfy_api):
|
||||
"""Initialize the Somfy device."""
|
||||
super().__init__(coordinator)
|
||||
self._id = device_id
|
||||
self.api = somfy_api
|
||||
|
||||
@property
|
||||
def device(self):
|
||||
"""Return data for the device id."""
|
||||
return self.coordinator.data[self._id]
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique id base on the id returned by Somfy."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the device."""
|
||||
return self.device.name
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device specific attributes.
|
||||
|
||||
Implemented by platform classes.
|
||||
"""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.unique_id)},
|
||||
"name": self.name,
|
||||
"model": self.device.type,
|
||||
"via_device": (DOMAIN, self.device.parent_id),
|
||||
# For the moment, Somfy only returns their own device.
|
||||
"manufacturer": "Somfy",
|
||||
}
|
||||
|
||||
def has_capability(self, capability: str) -> bool:
|
||||
"""Test if device has a capability."""
|
||||
capabilities = self.device.capabilities
|
||||
return bool([c for c in capabilities if c.name == capability])
|
||||
|
||||
def has_state(self, state: str) -> bool:
|
||||
"""Test if device has a state."""
|
||||
states = self.device.states
|
||||
return bool([c for c in states if c.name == state])
|
||||
|
||||
@property
|
||||
def assumed_state(self) -> bool:
|
||||
"""Return if the device has an assumed state."""
|
||||
return not bool(self.device.states)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self):
|
||||
"""Process an update from the coordinator."""
|
||||
self._create_device()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@abstractmethod
|
||||
def _create_device(self):
|
||||
"""Update the device with the latest data."""
|
||||
|
||||
@@ -23,8 +23,8 @@ from homeassistant.components.climate.const import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
|
||||
|
||||
from . import SomfyEntity
|
||||
from .const import API, COORDINATOR, DOMAIN
|
||||
from .const import COORDINATOR, DOMAIN
|
||||
from .entity import SomfyEntity
|
||||
|
||||
SUPPORTED_CATEGORIES = {Category.HVAC.value}
|
||||
|
||||
@@ -49,10 +49,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the Somfy climate platform."""
|
||||
domain_data = hass.data[DOMAIN]
|
||||
coordinator = domain_data[COORDINATOR]
|
||||
api = domain_data[API]
|
||||
|
||||
climates = [
|
||||
SomfyClimate(coordinator, device_id, api)
|
||||
SomfyClimate(coordinator, device_id)
|
||||
for device_id, device in coordinator.data.items()
|
||||
if SUPPORTED_CATEGORIES & set(device.categories)
|
||||
]
|
||||
@@ -63,15 +62,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
class SomfyClimate(SomfyEntity, ClimateEntity):
|
||||
"""Representation of a Somfy thermostat device."""
|
||||
|
||||
def __init__(self, coordinator, device_id, api):
|
||||
def __init__(self, coordinator, device_id):
|
||||
"""Initialize the Somfy device."""
|
||||
super().__init__(coordinator, device_id, api)
|
||||
super().__init__(coordinator, device_id)
|
||||
self._climate = None
|
||||
self._create_device()
|
||||
|
||||
def _create_device(self):
|
||||
"""Update the device with the latest data."""
|
||||
self._climate = Thermostat(self.device, self.api)
|
||||
self._climate = Thermostat(self.device, self.coordinator.client)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
|
||||
DOMAIN = "somfy"
|
||||
COORDINATOR = "coordinator"
|
||||
API = "api"
|
||||
|
||||
71
homeassistant/components/somfy/coordinator.py
Normal file
71
homeassistant/components/somfy/coordinator.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Helpers to help coordinate updated."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pymfy.api.error import QuotaViolationException, SetupNotFoundException
|
||||
from pymfy.api.model import Device
|
||||
from pymfy.api.somfy_api import SomfyApi
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
|
||||
class SomfyDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Class to manage fetching Somfy data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: logging.Logger,
|
||||
*,
|
||||
name: str,
|
||||
client: SomfyApi,
|
||||
update_interval: timedelta | None = None,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
self.data = {}
|
||||
self.client = client
|
||||
self.site_device = {}
|
||||
self.last_site_index = -1
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Device]:
|
||||
"""Fetch Somfy data.
|
||||
|
||||
Somfy only allow one call per minute to /site. There is one exception: 2 calls are allowed after site retrieval.
|
||||
"""
|
||||
if not self.site_device:
|
||||
sites = await self.hass.async_add_executor_job(self.client.get_sites)
|
||||
if not sites:
|
||||
return {}
|
||||
self.site_device = {site.id: [] for site in sites}
|
||||
|
||||
site_id = self._site_id
|
||||
try:
|
||||
devices = await self.hass.async_add_executor_job(
|
||||
self.client.get_devices, site_id
|
||||
)
|
||||
self.site_device[site_id] = devices
|
||||
except SetupNotFoundException:
|
||||
del self.site_device[site_id]
|
||||
return await self._async_update_data()
|
||||
except QuotaViolationException:
|
||||
self.logger.warning("Quota violation")
|
||||
|
||||
return {dev.id: dev for devices in self.site_device.values() for dev in devices}
|
||||
|
||||
@property
|
||||
def _site_id(self):
|
||||
"""Return the next site id to retrieve.
|
||||
|
||||
This tweak is required as Somfy does not allow to call the /site entrypoint more than once per minute.
|
||||
"""
|
||||
self.last_site_index = (self.last_site_index + 1) % len(self.site_device)
|
||||
return list(self.site_device.keys())[self.last_site_index]
|
||||
@@ -21,8 +21,8 @@ from homeassistant.components.cover import (
|
||||
from homeassistant.const import CONF_OPTIMISTIC, STATE_CLOSED, STATE_OPEN
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from . import SomfyEntity
|
||||
from .const import API, COORDINATOR, DOMAIN
|
||||
from .const import COORDINATOR, DOMAIN
|
||||
from .entity import SomfyEntity
|
||||
|
||||
BLIND_DEVICE_CATEGORIES = {Category.INTERIOR_BLIND.value, Category.EXTERIOR_BLIND.value}
|
||||
SHUTTER_DEVICE_CATEGORIES = {Category.EXTERIOR_BLIND.value}
|
||||
@@ -37,10 +37,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the Somfy cover platform."""
|
||||
domain_data = hass.data[DOMAIN]
|
||||
coordinator = domain_data[COORDINATOR]
|
||||
api = domain_data[API]
|
||||
|
||||
covers = [
|
||||
SomfyCover(coordinator, device_id, api, domain_data[CONF_OPTIMISTIC])
|
||||
SomfyCover(coordinator, device_id, domain_data[CONF_OPTIMISTIC])
|
||||
for device_id, device in coordinator.data.items()
|
||||
if SUPPORTED_CATEGORIES & set(device.categories)
|
||||
]
|
||||
@@ -51,9 +50,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
class SomfyCover(SomfyEntity, RestoreEntity, CoverEntity):
|
||||
"""Representation of a Somfy cover device."""
|
||||
|
||||
def __init__(self, coordinator, device_id, api, optimistic):
|
||||
def __init__(self, coordinator, device_id, optimistic):
|
||||
"""Initialize the Somfy device."""
|
||||
super().__init__(coordinator, device_id, api)
|
||||
super().__init__(coordinator, device_id)
|
||||
self.categories = set(self.device.categories)
|
||||
self.optimistic = optimistic
|
||||
self._closed = None
|
||||
@@ -64,7 +63,7 @@ class SomfyCover(SomfyEntity, RestoreEntity, CoverEntity):
|
||||
|
||||
def _create_device(self) -> Blind:
|
||||
"""Update the device with the latest data."""
|
||||
self._cover = Blind(self.device, self.api)
|
||||
self._cover = Blind(self.device, self.coordinator.client)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
|
||||
73
homeassistant/components/somfy/entity.py
Normal file
73
homeassistant/components/somfy/entity.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Entity representing a Somfy device."""
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class SomfyEntity(CoordinatorEntity, Entity):
|
||||
"""Representation of a generic Somfy device."""
|
||||
|
||||
def __init__(self, coordinator, device_id):
|
||||
"""Initialize the Somfy device."""
|
||||
super().__init__(coordinator)
|
||||
self._id = device_id
|
||||
|
||||
@property
|
||||
def device(self):
|
||||
"""Return data for the device id."""
|
||||
return self.coordinator.data[self._id]
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique id base on the id returned by Somfy."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the device."""
|
||||
return self.device.name
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device specific attributes.
|
||||
|
||||
Implemented by platform classes.
|
||||
"""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.unique_id)},
|
||||
"name": self.name,
|
||||
"model": self.device.type,
|
||||
"via_device": (DOMAIN, self.device.parent_id),
|
||||
# For the moment, Somfy only returns their own device.
|
||||
"manufacturer": "Somfy",
|
||||
}
|
||||
|
||||
def has_capability(self, capability: str) -> bool:
|
||||
"""Test if device has a capability."""
|
||||
capabilities = self.device.capabilities
|
||||
return bool([c for c in capabilities if c.name == capability])
|
||||
|
||||
def has_state(self, state: str) -> bool:
|
||||
"""Test if device has a state."""
|
||||
states = self.device.states
|
||||
return bool([c for c in states if c.name == state])
|
||||
|
||||
@property
|
||||
def assumed_state(self) -> bool:
|
||||
"""Return if the device has an assumed state."""
|
||||
return not bool(self.device.states)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self):
|
||||
"""Process an update from the coordinator."""
|
||||
self._create_device()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@abstractmethod
|
||||
def _create_device(self):
|
||||
"""Update the device with the latest data."""
|
||||
@@ -5,7 +5,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/somfy",
|
||||
"dependencies": ["http"],
|
||||
"codeowners": ["@tetienne"],
|
||||
"requirements": ["pymfy==0.9.3"],
|
||||
"requirements": ["pymfy==0.11.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_kizbox._tcp.local.",
|
||||
|
||||
@@ -6,8 +6,8 @@ from pymfy.api.devices.thermostat import Thermostat
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE
|
||||
|
||||
from . import SomfyEntity
|
||||
from .const import API, COORDINATOR, DOMAIN
|
||||
from .const import COORDINATOR, DOMAIN
|
||||
from .entity import SomfyEntity
|
||||
|
||||
SUPPORTED_CATEGORIES = {Category.HVAC.value}
|
||||
|
||||
@@ -16,10 +16,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the Somfy sensor platform."""
|
||||
domain_data = hass.data[DOMAIN]
|
||||
coordinator = domain_data[COORDINATOR]
|
||||
api = domain_data[API]
|
||||
|
||||
sensors = [
|
||||
SomfyThermostatBatterySensor(coordinator, device_id, api)
|
||||
SomfyThermostatBatterySensor(coordinator, device_id)
|
||||
for device_id, device in coordinator.data.items()
|
||||
if SUPPORTED_CATEGORIES & set(device.categories)
|
||||
]
|
||||
@@ -33,15 +32,15 @@ class SomfyThermostatBatterySensor(SomfyEntity, SensorEntity):
|
||||
_attr_device_class = DEVICE_CLASS_BATTERY
|
||||
_attr_unit_of_measurement = PERCENTAGE
|
||||
|
||||
def __init__(self, coordinator, device_id, api):
|
||||
def __init__(self, coordinator, device_id):
|
||||
"""Initialize the Somfy device."""
|
||||
super().__init__(coordinator, device_id, api)
|
||||
super().__init__(coordinator, device_id)
|
||||
self._climate = None
|
||||
self._create_device()
|
||||
|
||||
def _create_device(self):
|
||||
"""Update the device with the latest data."""
|
||||
self._climate = Thermostat(self.device, self.api)
|
||||
self._climate = Thermostat(self.device, self.coordinator.client)
|
||||
|
||||
@property
|
||||
def state(self) -> int:
|
||||
|
||||
@@ -4,18 +4,17 @@ from pymfy.api.devices.category import Category
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
|
||||
from . import SomfyEntity
|
||||
from .const import API, COORDINATOR, DOMAIN
|
||||
from .const import COORDINATOR, DOMAIN
|
||||
from .entity import SomfyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the Somfy switch platform."""
|
||||
domain_data = hass.data[DOMAIN]
|
||||
coordinator = domain_data[COORDINATOR]
|
||||
api = domain_data[API]
|
||||
|
||||
switches = [
|
||||
SomfyCameraShutter(coordinator, device_id, api)
|
||||
SomfyCameraShutter(coordinator, device_id)
|
||||
for device_id, device in coordinator.data.items()
|
||||
if Category.CAMERA.value in device.categories
|
||||
]
|
||||
@@ -26,14 +25,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
class SomfyCameraShutter(SomfyEntity, SwitchEntity):
|
||||
"""Representation of a Somfy Camera Shutter device."""
|
||||
|
||||
def __init__(self, coordinator, device_id, api):
|
||||
def __init__(self, coordinator, device_id):
|
||||
"""Initialize the Somfy device."""
|
||||
super().__init__(coordinator, device_id, api)
|
||||
super().__init__(coordinator, device_id)
|
||||
self._create_device()
|
||||
|
||||
def _create_device(self):
|
||||
"""Update the device with the latest data."""
|
||||
self.shutter = CameraProtect(self.device, self.api)
|
||||
self.shutter = CameraProtect(self.device, self.coordinator.client)
|
||||
|
||||
def turn_on(self, **kwargs) -> None:
|
||||
"""Turn the entity on."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_NAME,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
@@ -100,7 +101,9 @@ class ThresholdSensor(BinarySensorEntity):
|
||||
|
||||
try:
|
||||
self.sensor_value = (
|
||||
None if new_state.state == STATE_UNKNOWN else float(new_state.state)
|
||||
None
|
||||
if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]
|
||||
else float(new_state.state)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
self.sensor_value = None
|
||||
|
||||
@@ -507,7 +507,11 @@ class MiroboVacuum(XiaomiMiioEntity, StateVacuumEntity):
|
||||
|
||||
# Fetch timers separately, see #38285
|
||||
try:
|
||||
self._timers = self._device.timer()
|
||||
# Do not try this if the first fetch timed out.
|
||||
# Two timeouts take longer than 10 seconds and trigger a warning.
|
||||
# See #52353
|
||||
if self._available:
|
||||
self._timers = self._device.timer()
|
||||
except DeviceException as exc:
|
||||
_LOGGER.debug(
|
||||
"Unable to fetch timers, this may happen on some devices: %s", exc
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "zeroconf",
|
||||
"name": "Zero-configuration networking (zeroconf)",
|
||||
"documentation": "https://www.home-assistant.io/integrations/zeroconf",
|
||||
"requirements": ["zeroconf==0.32.0"],
|
||||
"requirements": ["zeroconf==0.32.1"],
|
||||
"dependencies": ["network", "api"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"quality_scale": "internal",
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
"bellows==0.25.0",
|
||||
"pyserial==3.5",
|
||||
"pyserial-asyncio==0.5",
|
||||
"zha-quirks==0.0.58",
|
||||
"zha-quirks==0.0.59",
|
||||
"zigpy-cc==0.5.2",
|
||||
"zigpy-deconz==0.12.0",
|
||||
"zigpy==0.35.0",
|
||||
"zigpy==0.35.1",
|
||||
"zigpy-xbee==0.13.0",
|
||||
"zigpy-zigate==0.7.3",
|
||||
"zigpy-znp==0.5.1"
|
||||
|
||||
@@ -469,15 +469,15 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
if not self._current_mode:
|
||||
# Thermostat(valve) with no support for setting a mode
|
||||
raise ValueError(
|
||||
f"Thermostat {self.entity_id} does not support setting a mode"
|
||||
)
|
||||
hvac_mode_value = self._hvac_modes.get(hvac_mode)
|
||||
if hvac_mode_value is None:
|
||||
hvac_mode_id = self._hvac_modes.get(hvac_mode)
|
||||
if hvac_mode_id is None:
|
||||
raise ValueError(f"Received an invalid hvac mode: {hvac_mode}")
|
||||
await self.info.node.async_set_value(self._current_mode, hvac_mode_value)
|
||||
|
||||
if not self._current_mode:
|
||||
# Thermostat(valve) has no support for setting a mode, so we make it a no-op
|
||||
return
|
||||
|
||||
await self.info.node.async_set_value(self._current_mode, hvac_mode_id)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target preset mode."""
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Final
|
||||
|
||||
MAJOR_VERSION: Final = 2021
|
||||
MINOR_VERSION: Final = 7
|
||||
PATCH_VERSION: Final = "0b3"
|
||||
PATCH_VERSION: Final = "0"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0)
|
||||
|
||||
@@ -176,7 +176,6 @@ FLOWS = [
|
||||
"netatmo",
|
||||
"nexia",
|
||||
"nightscout",
|
||||
"nmap_tracker",
|
||||
"notion",
|
||||
"nuheat",
|
||||
"nuki",
|
||||
|
||||
@@ -17,7 +17,7 @@ defusedxml==0.7.1
|
||||
distro==1.5.0
|
||||
emoji==1.2.0
|
||||
hass-nabucasa==0.44.0
|
||||
home-assistant-frontend==20210630.0
|
||||
home-assistant-frontend==20210707.0
|
||||
httpx==0.18.0
|
||||
ifaddr==0.1.7
|
||||
jinja2==3.0.1
|
||||
@@ -33,7 +33,7 @@ sqlalchemy==1.4.17
|
||||
voluptuous-serialize==2.4.0
|
||||
voluptuous==0.12.1
|
||||
yarl==1.6.3
|
||||
zeroconf==0.32.0
|
||||
zeroconf==0.32.1
|
||||
|
||||
pycryptodome>=3.6.6
|
||||
|
||||
|
||||
@@ -73,16 +73,6 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): # type: ignore[valid
|
||||
loop.set_default_executor = warn_use( # type: ignore
|
||||
loop.set_default_executor, "sets default executor on the event loop"
|
||||
)
|
||||
|
||||
# Shut down executor when we shut down loop
|
||||
orig_close = loop.close
|
||||
|
||||
def close() -> None:
|
||||
executor.logged_shutdown()
|
||||
orig_close()
|
||||
|
||||
loop.close = close # type: ignore
|
||||
|
||||
return loop
|
||||
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def join_or_interrupt_threads(
|
||||
class InterruptibleThreadPoolExecutor(ThreadPoolExecutor):
|
||||
"""A ThreadPoolExecutor instance that will not deadlock on shutdown."""
|
||||
|
||||
def logged_shutdown(self) -> None:
|
||||
def shutdown(self, *args, **kwargs) -> None: # type: ignore
|
||||
"""Shutdown backport from cpython 3.9 with interrupt support added."""
|
||||
with self._shutdown_lock: # type: ignore[attr-defined]
|
||||
self._shutdown = True
|
||||
|
||||
@@ -175,7 +175,7 @@ aioguardian==1.0.4
|
||||
aioharmony==0.2.7
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==0.4.1
|
||||
aiohomekit==0.4.2
|
||||
|
||||
# homeassistant.components.emulated_hue
|
||||
# homeassistant.components.http
|
||||
@@ -780,7 +780,7 @@ hole==0.5.1
|
||||
holidays==0.11.1
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20210630.0
|
||||
home-assistant-frontend==20210707.0
|
||||
|
||||
# homeassistant.components.zwave
|
||||
homeassistant-pyozw==0.1.10
|
||||
@@ -834,7 +834,6 @@ ibmiotf==0.3.4
|
||||
icmplib==3.0
|
||||
|
||||
# homeassistant.components.network
|
||||
# homeassistant.components.nmap_tracker
|
||||
ifaddr==0.1.7
|
||||
|
||||
# homeassistant.components.iglo
|
||||
@@ -936,9 +935,6 @@ lw12==0.9.2
|
||||
# homeassistant.components.lyft
|
||||
lyft_rides==0.2
|
||||
|
||||
# homeassistant.components.nmap_tracker
|
||||
mac-vendor-lookup==0.1.11
|
||||
|
||||
# homeassistant.components.magicseaweed
|
||||
magicseaweed==1.0.3
|
||||
|
||||
@@ -1017,9 +1013,6 @@ netdata==0.2.0
|
||||
# homeassistant.components.discovery
|
||||
netdisco==2.9.0
|
||||
|
||||
# homeassistant.components.nmap_tracker
|
||||
netmap==0.7.0.2
|
||||
|
||||
# homeassistant.components.nam
|
||||
nettigo-air-monitor==1.0.0
|
||||
|
||||
@@ -1409,7 +1402,7 @@ pyeconet==0.1.14
|
||||
pyedimax==0.2.1
|
||||
|
||||
# homeassistant.components.eight_sleep
|
||||
pyeight==0.1.8
|
||||
pyeight==0.1.9
|
||||
|
||||
# homeassistant.components.emby
|
||||
pyemby==1.7
|
||||
@@ -1590,7 +1583,7 @@ pymelcloud==2.5.3
|
||||
pymeteoclimatic==0.0.6
|
||||
|
||||
# homeassistant.components.somfy
|
||||
pymfy==0.9.3
|
||||
pymfy==0.11.0
|
||||
|
||||
# homeassistant.components.xiaomi_tv
|
||||
pymitv==1.4.3
|
||||
@@ -1756,7 +1749,7 @@ pysignalclirestapi==0.3.4
|
||||
pyskyqhub==0.1.3
|
||||
|
||||
# homeassistant.components.sma
|
||||
pysma==0.6.1
|
||||
pysma==0.6.2
|
||||
|
||||
# homeassistant.components.smappee
|
||||
pysmappee==0.2.25
|
||||
@@ -1869,6 +1862,9 @@ python-mystrom==1.1.2
|
||||
# homeassistant.components.nest
|
||||
python-nest==4.1.0
|
||||
|
||||
# homeassistant.components.nmap_tracker
|
||||
python-nmap==0.6.1
|
||||
|
||||
# homeassistant.components.ozw
|
||||
python-openzwave-mqtt[mqtt-client]==1.4.0
|
||||
|
||||
@@ -2106,7 +2102,7 @@ simplehound==0.3
|
||||
simplepush==1.1.4
|
||||
|
||||
# homeassistant.components.simplisafe
|
||||
simplisafe-python==10.0.0
|
||||
simplisafe-python==11.0.0
|
||||
|
||||
# homeassistant.components.sisyphus
|
||||
sisyphus-control==3.0
|
||||
@@ -2428,10 +2424,10 @@ zeep[async]==4.0.0
|
||||
zengge==0.2
|
||||
|
||||
# homeassistant.components.zeroconf
|
||||
zeroconf==0.32.0
|
||||
zeroconf==0.32.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.58
|
||||
zha-quirks==0.0.59
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong_hong_hvac==1.0.9
|
||||
@@ -2455,7 +2451,7 @@ zigpy-zigate==0.7.3
|
||||
zigpy-znp==0.5.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy==0.35.0
|
||||
zigpy==0.35.1
|
||||
|
||||
# homeassistant.components.zoneminder
|
||||
zm-py==0.5.2
|
||||
|
||||
@@ -112,7 +112,7 @@ aioguardian==1.0.4
|
||||
aioharmony==0.2.7
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==0.4.1
|
||||
aiohomekit==0.4.2
|
||||
|
||||
# homeassistant.components.emulated_hue
|
||||
# homeassistant.components.http
|
||||
@@ -447,7 +447,7 @@ hole==0.5.1
|
||||
holidays==0.11.1
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20210630.0
|
||||
home-assistant-frontend==20210707.0
|
||||
|
||||
# homeassistant.components.zwave
|
||||
homeassistant-pyozw==0.1.10
|
||||
@@ -481,7 +481,6 @@ iaqualink==0.3.90
|
||||
icmplib==3.0
|
||||
|
||||
# homeassistant.components.network
|
||||
# homeassistant.components.nmap_tracker
|
||||
ifaddr==0.1.7
|
||||
|
||||
# homeassistant.components.influxdb
|
||||
@@ -523,9 +522,6 @@ logi_circle==0.2.2
|
||||
# homeassistant.components.luftdaten
|
||||
luftdaten==0.6.5
|
||||
|
||||
# homeassistant.components.nmap_tracker
|
||||
mac-vendor-lookup==0.1.11
|
||||
|
||||
# homeassistant.components.maxcube
|
||||
maxcube-api==0.4.3
|
||||
|
||||
@@ -574,9 +570,6 @@ nessclient==0.9.15
|
||||
# homeassistant.components.discovery
|
||||
netdisco==2.9.0
|
||||
|
||||
# homeassistant.components.nmap_tracker
|
||||
netmap==0.7.0.2
|
||||
|
||||
# homeassistant.components.nam
|
||||
nettigo-air-monitor==1.0.0
|
||||
|
||||
@@ -904,7 +897,7 @@ pymelcloud==2.5.3
|
||||
pymeteoclimatic==0.0.6
|
||||
|
||||
# homeassistant.components.somfy
|
||||
pymfy==0.9.3
|
||||
pymfy==0.11.0
|
||||
|
||||
# homeassistant.components.mochad
|
||||
pymochad==0.2.0
|
||||
@@ -998,7 +991,7 @@ pysiaalarm==3.0.0
|
||||
pysignalclirestapi==0.3.4
|
||||
|
||||
# homeassistant.components.sma
|
||||
pysma==0.6.1
|
||||
pysma==0.6.2
|
||||
|
||||
# homeassistant.components.smappee
|
||||
pysmappee==0.2.25
|
||||
@@ -1155,7 +1148,7 @@ sharkiqpy==0.1.8
|
||||
simplehound==0.3
|
||||
|
||||
# homeassistant.components.simplisafe
|
||||
simplisafe-python==10.0.0
|
||||
simplisafe-python==11.0.0
|
||||
|
||||
# homeassistant.components.slack
|
||||
slackclient==2.5.0
|
||||
@@ -1331,10 +1324,10 @@ yeelight==0.6.3
|
||||
zeep[async]==4.0.0
|
||||
|
||||
# homeassistant.components.zeroconf
|
||||
zeroconf==0.32.0
|
||||
zeroconf==0.32.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.58
|
||||
zha-quirks==0.0.59
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-cc==0.5.2
|
||||
@@ -1352,7 +1345,7 @@ zigpy-zigate==0.7.3
|
||||
zigpy-znp==0.5.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy==0.35.0
|
||||
zigpy==0.35.1
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.27.0
|
||||
|
||||
@@ -61,8 +61,8 @@ async def test_setup_entry_successful(hass, aioclient_mock):
|
||||
config_entry = await setup_deconz_integration(hass, aioclient_mock)
|
||||
|
||||
assert hass.data[DECONZ_DOMAIN]
|
||||
assert config_entry.unique_id in hass.data[DECONZ_DOMAIN]
|
||||
assert hass.data[DECONZ_DOMAIN][config_entry.unique_id].master
|
||||
assert config_entry.entry_id in hass.data[DECONZ_DOMAIN]
|
||||
assert hass.data[DECONZ_DOMAIN][config_entry.entry_id].master
|
||||
|
||||
|
||||
async def test_setup_entry_multiple_gateways(hass, aioclient_mock):
|
||||
@@ -80,8 +80,8 @@ async def test_setup_entry_multiple_gateways(hass, aioclient_mock):
|
||||
)
|
||||
|
||||
assert len(hass.data[DECONZ_DOMAIN]) == 2
|
||||
assert hass.data[DECONZ_DOMAIN][config_entry.unique_id].master
|
||||
assert not hass.data[DECONZ_DOMAIN][config_entry2.unique_id].master
|
||||
assert hass.data[DECONZ_DOMAIN][config_entry.entry_id].master
|
||||
assert not hass.data[DECONZ_DOMAIN][config_entry2.entry_id].master
|
||||
|
||||
|
||||
async def test_unload_entry(hass, aioclient_mock):
|
||||
@@ -112,7 +112,7 @@ async def test_unload_entry_multiple_gateways(hass, aioclient_mock):
|
||||
assert await async_unload_entry(hass, config_entry)
|
||||
|
||||
assert len(hass.data[DECONZ_DOMAIN]) == 1
|
||||
assert hass.data[DECONZ_DOMAIN][config_entry2.unique_id].master
|
||||
assert hass.data[DECONZ_DOMAIN][config_entry2.entry_id].master
|
||||
|
||||
|
||||
async def test_update_group_unique_id(hass):
|
||||
|
||||
@@ -152,8 +152,27 @@ async def test_configure_service_with_entity_and_field(hass, aioclient_mock):
|
||||
assert aioclient_mock.mock_calls[1][2] == {"on": True, "attr1": 10, "attr2": 20}
|
||||
|
||||
|
||||
async def test_configure_service_with_faulty_bridgeid(hass, aioclient_mock):
|
||||
"""Test that service fails on a bad bridge id."""
|
||||
await setup_deconz_integration(hass, aioclient_mock)
|
||||
aioclient_mock.clear_requests()
|
||||
|
||||
data = {
|
||||
CONF_BRIDGE_ID: "Bad bridge id",
|
||||
SERVICE_FIELD: "/lights/1",
|
||||
SERVICE_DATA: {"on": True},
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
DECONZ_DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(aioclient_mock.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_configure_service_with_faulty_field(hass, aioclient_mock):
|
||||
"""Test that service invokes pydeconz with the correct path and data."""
|
||||
"""Test that service fails on a bad field."""
|
||||
await setup_deconz_integration(hass, aioclient_mock)
|
||||
|
||||
data = {SERVICE_FIELD: "light/2", SERVICE_DATA: {}}
|
||||
@@ -166,7 +185,7 @@ async def test_configure_service_with_faulty_field(hass, aioclient_mock):
|
||||
|
||||
|
||||
async def test_configure_service_with_faulty_entity(hass, aioclient_mock):
|
||||
"""Test that service invokes pydeconz with the correct path and data."""
|
||||
"""Test that service on a non existing entity."""
|
||||
await setup_deconz_integration(hass, aioclient_mock)
|
||||
aioclient_mock.clear_requests()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for homekit_controller config flow."""
|
||||
from unittest import mock
|
||||
import unittest.mock
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import aiohomekit
|
||||
from aiohomekit.model import Accessories, Accessory
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.homekit_controller import config_flow
|
||||
from homeassistant.components.homekit_controller.const import KNOWN_DEVICES
|
||||
from homeassistant.helpers import device_registry
|
||||
|
||||
from tests.common import MockConfigEntry, mock_device_registry
|
||||
@@ -383,11 +384,16 @@ async def test_discovery_invalid_config_entry(hass, controller):
|
||||
|
||||
async def test_discovery_already_configured(hass, controller):
|
||||
"""Already configured."""
|
||||
MockConfigEntry(
|
||||
entry = MockConfigEntry(
|
||||
domain="homekit_controller",
|
||||
data={"AccessoryPairingID": "00:00:00:00:00:00"},
|
||||
data={
|
||||
"AccessoryIP": "4.4.4.4",
|
||||
"AccessoryPort": 66,
|
||||
"AccessoryPairingID": "00:00:00:00:00:00",
|
||||
},
|
||||
unique_id="00:00:00:00:00:00",
|
||||
).add_to_hass(hass)
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
device = setup_mock_accessory(controller)
|
||||
discovery_info = get_device_discovery_info(device)
|
||||
@@ -403,6 +409,49 @@ async def test_discovery_already_configured(hass, controller):
|
||||
)
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
assert entry.data["AccessoryIP"] == discovery_info["host"]
|
||||
assert entry.data["AccessoryPort"] == discovery_info["port"]
|
||||
|
||||
|
||||
async def test_discovery_already_configured_update_csharp(hass, controller):
|
||||
"""Already configured and csharp changes."""
|
||||
entry = MockConfigEntry(
|
||||
domain="homekit_controller",
|
||||
data={
|
||||
"AccessoryIP": "4.4.4.4",
|
||||
"AccessoryPort": 66,
|
||||
"AccessoryPairingID": "AA:BB:CC:DD:EE:FF",
|
||||
},
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
connection_mock = AsyncMock()
|
||||
connection_mock.pairing.connect.reconnect_soon = AsyncMock()
|
||||
connection_mock.async_refresh_entity_map = AsyncMock()
|
||||
hass.data[KNOWN_DEVICES] = {"AA:BB:CC:DD:EE:FF": connection_mock}
|
||||
|
||||
device = setup_mock_accessory(controller)
|
||||
discovery_info = get_device_discovery_info(device)
|
||||
|
||||
# Set device as already paired
|
||||
discovery_info["properties"]["sf"] = 0x00
|
||||
discovery_info["properties"]["c#"] = 99999
|
||||
discovery_info["properties"]["id"] = "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
# Device is discovered
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"homekit_controller",
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=discovery_info,
|
||||
)
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data["AccessoryIP"] == discovery_info["host"]
|
||||
assert entry.data["AccessoryPort"] == discovery_info["port"]
|
||||
assert connection_mock.async_refresh_entity_map.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exception,expected", PAIRING_START_ABORT_ERRORS)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for the Nmap Tracker integration."""
|
||||
@@ -1,310 +0,0 @@
|
||||
"""Test the Nmap Tracker config flow."""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries, data_entry_flow, setup
|
||||
from homeassistant.components.device_tracker.const import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TRACK_NEW,
|
||||
)
|
||||
from homeassistant.components.nmap_tracker.const import (
|
||||
CONF_HOME_INTERVAL,
|
||||
CONF_OPTIONS,
|
||||
DEFAULT_OPTIONS,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hosts", ["1.1.1.1", "192.168.1.0/24", "192.168.1.0/24,192.168.2.0/24"]
|
||||
)
|
||||
async def test_form(hass: HomeAssistant, hosts: str) -> None:
|
||||
"""Test we get the form."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
schema_defaults = result["data_schema"]({})
|
||||
assert CONF_TRACK_NEW not in schema_defaults
|
||||
assert CONF_SCAN_INTERVAL not in schema_defaults
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.nmap_tracker.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOSTS: hosts,
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "4.4.4.4",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == f"Nmap Tracker {hosts}"
|
||||
assert result2["data"] == {}
|
||||
assert result2["options"] == {
|
||||
CONF_HOSTS: hosts,
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "4.4.4.4",
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_range(hass: HomeAssistant) -> None:
|
||||
"""Test we get the form and can take an ip range."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.nmap_tracker.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOSTS: "192.168.0.5-12",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "4.4.4.4",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == "Nmap Tracker 192.168.0.5-12"
|
||||
assert result2["data"] == {}
|
||||
assert result2["options"] == {
|
||||
CONF_HOSTS: "192.168.0.5-12",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "4.4.4.4",
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_invalid_hosts(hass: HomeAssistant) -> None:
|
||||
"""Test invalid hosts passed in."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOSTS: "not an ip block",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {CONF_HOSTS: "invalid_hosts"}
|
||||
|
||||
|
||||
async def test_form_already_configured(hass: HomeAssistant) -> None:
|
||||
"""Test duplicate host list."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={
|
||||
CONF_HOSTS: "192.168.0.0/20",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "4.4.4.4",
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOSTS: "192.168.0.0/20",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "abort"
|
||||
assert result2["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_form_invalid_excludes(hass: HomeAssistant) -> None:
|
||||
"""Test invalid excludes passed in."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOSTS: "3.3.3.3",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "not an exclude",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {CONF_EXCLUDE: "invalid_hosts"}
|
||||
|
||||
|
||||
async def test_options_flow(hass: HomeAssistant) -> None:
|
||||
"""Test we can edit options."""
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={
|
||||
CONF_HOSTS: "192.168.1.0/24",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "4.4.4.4",
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
hass.state = CoreState.stopped
|
||||
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
assert result["data_schema"]({}) == {
|
||||
CONF_EXCLUDE: "4.4.4.4",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_HOSTS: "192.168.1.0/24",
|
||||
CONF_SCAN_INTERVAL: 120,
|
||||
CONF_OPTIONS: "-F --host-timeout 5s",
|
||||
CONF_TRACK_NEW: True,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.nmap_tracker.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_HOSTS: "192.168.1.0/24, 192.168.2.0/24",
|
||||
CONF_HOME_INTERVAL: 5,
|
||||
CONF_OPTIONS: "-sn",
|
||||
CONF_EXCLUDE: "4.4.4.4, 5.5.5.5",
|
||||
CONF_SCAN_INTERVAL: 10,
|
||||
CONF_TRACK_NEW: False,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert config_entry.options == {
|
||||
CONF_HOSTS: "192.168.1.0/24,192.168.2.0/24",
|
||||
CONF_HOME_INTERVAL: 5,
|
||||
CONF_OPTIONS: "-sn",
|
||||
CONF_EXCLUDE: "4.4.4.4,5.5.5.5",
|
||||
CONF_SCAN_INTERVAL: 10,
|
||||
CONF_TRACK_NEW: False,
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_import(hass: HomeAssistant) -> None:
|
||||
"""Test we can import from yaml."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
with patch(
|
||||
"homeassistant.components.nmap_tracker.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={
|
||||
CONF_HOSTS: "1.2.3.4/20",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "4.4.4.4, 6.4.3.2",
|
||||
CONF_SCAN_INTERVAL: 2000,
|
||||
CONF_TRACK_NEW: False,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == "Nmap Tracker 1.2.3.4/20"
|
||||
assert result["data"] == {}
|
||||
assert result["options"] == {
|
||||
CONF_HOSTS: "1.2.3.4/20",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "4.4.4.4,6.4.3.2",
|
||||
CONF_SCAN_INTERVAL: 2000,
|
||||
CONF_TRACK_NEW: False,
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_import_aborts_if_matching(hass: HomeAssistant) -> None:
|
||||
"""Test we can import from yaml."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={
|
||||
CONF_HOSTS: "192.168.0.0/20",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "4.4.4.4",
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={
|
||||
CONF_HOSTS: "192.168.0.0/20",
|
||||
CONF_HOME_INTERVAL: 3,
|
||||
CONF_OPTIONS: DEFAULT_OPTIONS,
|
||||
CONF_EXCLUDE: "4.4.4.4, 6.4.3.2",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Define tests for the SimpliSafe config flow."""
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from simplipy.errors import (
|
||||
InvalidCredentialsError,
|
||||
@@ -10,18 +10,11 @@ from simplipy.errors import (
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.simplisafe import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
|
||||
from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
def mock_api():
|
||||
"""Mock SimpliSafe API class."""
|
||||
api = MagicMock()
|
||||
type(api).refresh_token = PropertyMock(return_value="12345abc")
|
||||
return api
|
||||
|
||||
|
||||
async def test_duplicate_error(hass):
|
||||
"""Test that errors are shown when duplicates are added."""
|
||||
conf = {
|
||||
@@ -33,7 +26,11 @@ async def test_duplicate_error(hass):
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="user@email.com",
|
||||
data={CONF_USERNAME: "user@email.com", CONF_TOKEN: "12345", CONF_CODE: "1234"},
|
||||
data={
|
||||
CONF_USERNAME: "user@email.com",
|
||||
CONF_PASSWORD: "password",
|
||||
CONF_CODE: "1234",
|
||||
},
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -49,7 +46,7 @@ async def test_invalid_credentials(hass):
|
||||
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
|
||||
|
||||
with patch(
|
||||
"simplipy.API.login_via_credentials",
|
||||
"homeassistant.components.simplisafe.config_flow.get_api",
|
||||
new=AsyncMock(side_effect=InvalidCredentialsError),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -102,7 +99,11 @@ async def test_step_reauth(hass):
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="user@email.com",
|
||||
data={CONF_USERNAME: "user@email.com", CONF_TOKEN: "12345", CONF_CODE: "1234"},
|
||||
data={
|
||||
CONF_USERNAME: "user@email.com",
|
||||
CONF_PASSWORD: "password",
|
||||
CONF_CODE: "1234",
|
||||
},
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -118,8 +119,8 @@ async def test_step_reauth(hass):
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.simplisafe.async_setup_entry", return_value=True
|
||||
), patch(
|
||||
"simplipy.API.login_via_credentials", new=AsyncMock(return_value=mock_api())
|
||||
), patch("homeassistant.components.simplisafe.config_flow.get_api"), patch(
|
||||
"homeassistant.config_entries.ConfigEntries.async_reload"
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PASSWORD: "password"}
|
||||
@@ -141,7 +142,7 @@ async def test_step_user(hass):
|
||||
with patch(
|
||||
"homeassistant.components.simplisafe.async_setup_entry", return_value=True
|
||||
), patch(
|
||||
"simplipy.API.login_via_credentials", new=AsyncMock(return_value=mock_api())
|
||||
"homeassistant.components.simplisafe.config_flow.get_api", new=AsyncMock()
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=conf
|
||||
@@ -151,7 +152,7 @@ async def test_step_user(hass):
|
||||
assert result["title"] == "user@email.com"
|
||||
assert result["data"] == {
|
||||
CONF_USERNAME: "user@email.com",
|
||||
CONF_TOKEN: "12345abc",
|
||||
CONF_PASSWORD: "password",
|
||||
CONF_CODE: "1234",
|
||||
}
|
||||
|
||||
@@ -165,7 +166,7 @@ async def test_step_user_mfa(hass):
|
||||
}
|
||||
|
||||
with patch(
|
||||
"simplipy.API.login_via_credentials",
|
||||
"homeassistant.components.simplisafe.config_flow.get_api",
|
||||
new=AsyncMock(side_effect=PendingAuthorizationError),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -174,7 +175,7 @@ async def test_step_user_mfa(hass):
|
||||
assert result["step_id"] == "mfa"
|
||||
|
||||
with patch(
|
||||
"simplipy.API.login_via_credentials",
|
||||
"homeassistant.components.simplisafe.config_flow.get_api",
|
||||
new=AsyncMock(side_effect=PendingAuthorizationError),
|
||||
):
|
||||
# Simulate the user pressing the MFA submit button without having clicked
|
||||
@@ -187,7 +188,7 @@ async def test_step_user_mfa(hass):
|
||||
with patch(
|
||||
"homeassistant.components.simplisafe.async_setup_entry", return_value=True
|
||||
), patch(
|
||||
"simplipy.API.login_via_credentials", new=AsyncMock(return_value=mock_api())
|
||||
"homeassistant.components.simplisafe.config_flow.get_api", new=AsyncMock()
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
@@ -197,7 +198,7 @@ async def test_step_user_mfa(hass):
|
||||
assert result["title"] == "user@email.com"
|
||||
assert result["data"] == {
|
||||
CONF_USERNAME: "user@email.com",
|
||||
CONF_TOKEN: "12345abc",
|
||||
CONF_PASSWORD: "password",
|
||||
CONF_CODE: "1234",
|
||||
}
|
||||
|
||||
@@ -207,7 +208,7 @@ async def test_unknown_error(hass):
|
||||
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
|
||||
|
||||
with patch(
|
||||
"simplipy.API.login_via_credentials",
|
||||
"homeassistant.components.simplisafe.config_flow.get_api",
|
||||
new=AsyncMock(side_effect=SimplipyError),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
|
||||
@@ -82,7 +82,9 @@ async def test_full_flow(
|
||||
},
|
||||
)
|
||||
|
||||
with patch("homeassistant.components.somfy.api.ConfigEntrySomfyApi"):
|
||||
with patch(
|
||||
"homeassistant.components.somfy.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["data"]["auth_implementation"] == DOMAIN
|
||||
@@ -95,12 +97,7 @@ async def test_full_flow(
|
||||
"expires_in": 60,
|
||||
}
|
||||
|
||||
assert DOMAIN in hass.config.components
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert entry.state is config_entries.ConfigEntryState.LOADED
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_abort_if_authorization_timeout(hass, current_request_with_host):
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"""The test for the threshold sensor platform."""
|
||||
|
||||
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
TEMP_CELSIUS,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
@@ -283,7 +288,7 @@ async def test_sensor_in_range_with_hysteresis(hass):
|
||||
assert state.state == "on"
|
||||
|
||||
|
||||
async def test_sensor_in_range_unknown_state(hass):
|
||||
async def test_sensor_in_range_unknown_state(hass, caplog):
|
||||
"""Test if source is within the range."""
|
||||
config = {
|
||||
"binary_sensor": {
|
||||
@@ -322,6 +327,16 @@ async def test_sensor_in_range_unknown_state(hass):
|
||||
assert state.attributes.get("position") == "unknown"
|
||||
assert state.state == "off"
|
||||
|
||||
hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("binary_sensor.threshold")
|
||||
|
||||
assert state.attributes.get("position") == "unknown"
|
||||
assert state.state == "off"
|
||||
|
||||
assert "State is not numerical" not in caplog.text
|
||||
|
||||
|
||||
async def test_sensor_lower_zero_threshold(hass):
|
||||
"""Test if a lower threshold of zero is set."""
|
||||
|
||||
@@ -382,6 +382,30 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Test setting illegal mode raises an error
|
||||
with pytest.raises(ValueError):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: CLIMATE_DANFOSS_LC13_ENTITY,
|
||||
ATTR_HVAC_MODE: HVAC_MODE_COOL,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Test that setting HVAC_MODE_HEAT works. If the no-op logic didn't work, this would
|
||||
# raise an error
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: CLIMATE_DANFOSS_LC13_ENTITY,
|
||||
ATTR_HVAC_MODE: HVAC_MODE_HEAT,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert len(client.async_send_command_no_wait.call_args_list) == 1
|
||||
args = client.async_send_command_no_wait.call_args_list[0][0][0]
|
||||
assert args["command"] == "node.set_value"
|
||||
|
||||
@@ -24,7 +24,7 @@ async def test_executor_shutdown_can_interrupt_threads(caplog):
|
||||
for _ in range(100):
|
||||
sleep_futures.append(iexecutor.submit(_loop_sleep_in_executor))
|
||||
|
||||
iexecutor.logged_shutdown()
|
||||
iexecutor.shutdown()
|
||||
|
||||
for future in sleep_futures:
|
||||
with pytest.raises((concurrent.futures.CancelledError, SystemExit)):
|
||||
@@ -45,13 +45,13 @@ async def test_executor_shutdown_only_logs_max_attempts(caplog):
|
||||
iexecutor.submit(_loop_sleep_in_executor)
|
||||
|
||||
with patch.object(executor, "EXECUTOR_SHUTDOWN_TIMEOUT", 0.3):
|
||||
iexecutor.logged_shutdown()
|
||||
iexecutor.shutdown()
|
||||
|
||||
assert "time.sleep(0.2)" in caplog.text
|
||||
assert (
|
||||
caplog.text.count("is still running at shutdown") == executor.MAX_LOG_ATTEMPTS
|
||||
)
|
||||
iexecutor.logged_shutdown()
|
||||
iexecutor.shutdown()
|
||||
|
||||
|
||||
async def test_executor_shutdown_does_not_log_shutdown_on_first_attempt(caplog):
|
||||
@@ -65,7 +65,7 @@ async def test_executor_shutdown_does_not_log_shutdown_on_first_attempt(caplog):
|
||||
for _ in range(5):
|
||||
iexecutor.submit(_do_nothing)
|
||||
|
||||
iexecutor.logged_shutdown()
|
||||
iexecutor.shutdown()
|
||||
|
||||
assert "is still running at shutdown" not in caplog.text
|
||||
|
||||
@@ -83,9 +83,9 @@ async def test_overall_timeout_reached(caplog):
|
||||
|
||||
start = time.monotonic()
|
||||
with patch.object(executor, "EXECUTOR_SHUTDOWN_TIMEOUT", 0.5):
|
||||
iexecutor.logged_shutdown()
|
||||
iexecutor.shutdown()
|
||||
finish = time.monotonic()
|
||||
|
||||
assert finish - start < 1
|
||||
|
||||
iexecutor.logged_shutdown()
|
||||
iexecutor.shutdown()
|
||||
|
||||
Reference in New Issue
Block a user