Compare commits

..

27 Commits

Author SHA1 Message Date
Franck Nijhof
933e016150 Merge pull request #52627 from home-assistant/rc 2021-07-07 14:23:39 +02:00
Franck Nijhof
342366750b Bumped version to 2021.7.0 2021-07-07 13:09:52 +02:00
Franck Nijhof
a048809ca7 Bumped version to 2021.7.0b6 2021-07-07 11:21:23 +02:00
Bram Kragten
f7c844d728 Update frontend to 20210707.0 (#52624) 2021-07-07 11:21:06 +02:00
Franck Nijhof
998ffeb21d Fix broadlink creating duplicate unique IDs (#52621) 2021-07-07 11:21:03 +02:00
J. Nick Koston
a794c09a0f Fix deadlock at shutdown with python 3.9 (#52613) 2021-07-07 11:20:59 +02:00
Alexei Chetroi
a7ee86730c Bump up ZHA dependencies (#52611) 2021-07-07 11:20:56 +02:00
Chris
dd26bfb92b Fix mysensors rgb light (#52604)
* remove assert self._white as not all RGB will have a white channel

* suggested change

* Update homeassistant/components/mysensors/light.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2021-07-07 11:20:50 +02:00
Franck Nijhof
b14b284e62 Bumped version to 2021.7.0b5 2021-07-06 18:51:38 +02:00
Simone Chemelli
bad2525a6d Fix Fritz Wi-Fi 6 networks with same name as other Wi-Fi (#52588) 2021-07-06 18:51:23 +02:00
Robert Svensson
7a503a6c1f Make use of entry id rather than unique id when storing deconz entry in hass.data (#52584)
* Make use of entry id rather than unique id when storing entry in hass data

* Update homeassistant/components/deconz/services.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
2021-07-06 18:51:19 +02:00
J. Nick Koston
40d9541d9b Revert nmap_tracker to 2021.6 version (#52573)
* Revert nmap_tracker to 2021.6 version

- Its unlikely we will be able to solve #52565 before release

* hassfest
2021-07-06 18:51:16 +02:00
Aaron Bach
746a52bb27 Fresh attempt at SimpliSafe auto-relogin (#52567)
* Fresh attempt at SimpliSafe auto-relogin

* Fix tests
2021-07-06 18:51:12 +02:00
ondras12345
90f4b3a4ed Fix update of Xiaomi Miio vacuum taking too long (#52539)
Home assistant log would get spammed with messages like
Update of vacuum.vacuum_name is taking over 10 seconds
every 20 seconds if the vacuum was not reachable through the network.

See #52353
2021-07-06 18:51:08 +02:00
Shay Levy
2c75e3fe99 Fix Sensibo timeout exceptions (#52513) 2021-07-06 18:51:05 +02:00
Raman Gupta
e1c14b5a30 Don't raise when setting HVAC mode without a mode ZwaveValue (#52444)
* Don't raise an error when setting HVAC mode without a value

* change logic based on discord convo and add tests

* tweak
2021-07-06 18:51:01 +02:00
Thibaut
631e555e25 Update Somfy to reduce calls to /site entrypoint (#51572)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2021-07-06 18:50:57 +02:00
Bram Kragten
422de2c56d Bumped version to 2021.7.0b4 2021-07-06 11:57:50 +02:00
Bram Kragten
2356c1e52a Update frontend to 20210706.0 (#52577) 2021-07-06 11:54:32 +02:00
Raman Gupta
2220c8cd3f Bump pyeight version to 0.1.9 (#52568) 2021-07-06 11:54:31 +02:00
Franck Nijhof
979d37dc19 Fix unavailable entity capable of triggering non-numerical warning in Threshold sensor (#52563) 2021-07-06 11:54:30 +02:00
J. Nick Koston
701fa06584 Bump aiohomekit to 0.4.2 (#52560)
- Changelog: https://github.com/Jc2k/aiohomekit/compare/0.4.1...0.4.2

- Fixes: #52548
2021-07-06 11:54:29 +02:00
Alexei Chetroi
1c9053fef6 Bump up zha dependencies (#52555) 2021-07-06 11:54:28 +02:00
J. Nick Koston
777cf116aa Update the ip/port in the homekit_controller config entry when it changes (#52554) 2021-07-06 11:54:27 +02:00
René Klomp
a52b4b0f62 Bump pysma version to 0.6.2 (#52553) 2021-07-06 11:54:26 +02:00
J. Nick Koston
dfce89f2c7 Bump zeroconf to 0.32.1 (#52547)
- Changelog: https://github.com/jstasiak/python-zeroconf/compare/0.32.0...0.32.1

- Fixes #52384
2021-07-06 11:54:25 +02:00
Tom Brien
0cd097cd12 Update list of supported Coinbase wallet currencies (#52545) 2021-07-06 11:54:24 +02:00
55 changed files with 806 additions and 1489 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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."""

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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:

View File

@@ -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(

View File

@@ -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"
}

View File

@@ -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:

View File

@@ -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",

View File

@@ -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

View File

@@ -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"],

View File

@@ -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()

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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."""

View File

@@ -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,
}
)

View File

@@ -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"
}

View File

@@ -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%]"
}

View File

@@ -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": {

View File

@@ -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"
}

View File

@@ -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."""

View File

@@ -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:

View File

@@ -2,4 +2,3 @@
DOMAIN = "somfy"
COORDINATOR = "coordinator"
API = "api"

View 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]

View File

@@ -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:

View 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."""

View File

@@ -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.",

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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"

View File

@@ -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."""

View File

@@ -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)

View File

@@ -176,7 +176,6 @@ FLOWS = [
"netatmo",
"nexia",
"nightscout",
"nmap_tracker",
"notion",
"nuheat",
"nuki",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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()

View File

@@ -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)

View File

@@ -1 +0,0 @@
"""Tests for the Nmap Tracker integration."""

View File

@@ -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"

View File

@@ -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(

View File

@@ -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):

View File

@@ -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."""

View File

@@ -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"

View File

@@ -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()