diff --git a/.coveragerc b/.coveragerc index 4faf71102f82848020afcce7164449cbfca28c3b..2c768060108dccb7f9b4e8b55796f6b2b0e29acf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -314,6 +314,9 @@ omit = homeassistant/components/freebox/sensor.py homeassistant/components/freebox/switch.py homeassistant/components/fritz/device_tracker.py + homeassistant/components/fritzbox_callmonitor/__init__.py + homeassistant/components/fritzbox_callmonitor/const.py + homeassistant/components/fritzbox_callmonitor/base.py homeassistant/components/fritzbox_callmonitor/sensor.py homeassistant/components/fritzbox_netmonitor/sensor.py homeassistant/components/fronius/sensor.py diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py index f9a520216067d756bf3609b22b75b0a232d9ae15..933dd797dfcef42421f3578eb4679426024899c5 100644 --- a/homeassistant/components/fritzbox_callmonitor/__init__.py +++ b/homeassistant/components/fritzbox_callmonitor/__init__.py @@ -1 +1,92 @@ -"""The fritzbox_callmonitor component.""" +"""The fritzbox_callmonitor integration.""" +from asyncio import gather +import logging + +from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryNotReady + +from .base import FritzBoxPhonebook +from .const import ( + CONF_PHONEBOOK, + CONF_PREFIXES, + DOMAIN, + FRITZBOX_PHONEBOOK, + PLATFORMS, + UNDO_UPDATE_LISTENER, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up the fritzbox_callmonitor integration.""" + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the fritzbox_callmonitor platforms.""" + fritzbox_phonebook = FritzBoxPhonebook( + host=config_entry.data[CONF_HOST], + username=config_entry.data[CONF_USERNAME], + password=config_entry.data[CONF_PASSWORD], + phonebook_id=config_entry.data[CONF_PHONEBOOK], + prefixes=config_entry.options.get(CONF_PREFIXES), + ) + + try: + await hass.async_add_executor_job(fritzbox_phonebook.init_phonebook) + except FritzSecurityError as ex: + _LOGGER.error( + "User has insufficient permissions to access AVM FRITZ!Box settings and its phonebooks: %s", + ex, + ) + return False + except FritzConnectionException as ex: + _LOGGER.error("Invalid authentication: %s", ex) + return False + except RequestsConnectionError as ex: + _LOGGER.error("Unable to connect to AVM FRITZ!Box call monitor: %s", ex) + raise ConfigEntryNotReady from ex + + undo_listener = config_entry.add_update_listener(update_listener) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = { + FRITZBOX_PHONEBOOK: fritzbox_phonebook, + UNDO_UPDATE_LISTENER: undo_listener, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unloading the fritzbox_callmonitor platforms.""" + + unload_ok = all( + await gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) + + hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +async def update_listener(hass, config_entry): + """Update listener to reload after option has changed.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/fritzbox_callmonitor/base.py b/homeassistant/components/fritzbox_callmonitor/base.py new file mode 100644 index 0000000000000000000000000000000000000000..79f82de95b7959af34d749d11d9f8703b1a58754 --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/base.py @@ -0,0 +1,79 @@ +"""Base class for fritzbox_callmonitor entities.""" +from datetime import timedelta +import logging +import re + +from fritzconnection.lib.fritzphonebook import FritzPhonebook + +from homeassistant.util import Throttle + +from .const import REGEX_NUMBER, UNKOWN_NAME + +_LOGGER = logging.getLogger(__name__) + +# Return cached results if phonebook was downloaded less then this time ago. +MIN_TIME_PHONEBOOK_UPDATE = timedelta(hours=6) + + +class FritzBoxPhonebook: + """This connects to a FritzBox router and downloads its phone book.""" + + def __init__(self, host, username, password, phonebook_id, prefixes): + """Initialize the class.""" + self.host = host + self.username = username + self.password = password + self.phonebook_id = phonebook_id + self.phonebook_dict = None + self.number_dict = None + self.prefixes = prefixes + self.fph = None + + def init_phonebook(self): + """Establish a connection to the FRITZ!Box and check if phonebook_id is valid.""" + self.fph = FritzPhonebook( + address=self.host, + user=self.username, + password=self.password, + ) + self.update_phonebook() + + @Throttle(MIN_TIME_PHONEBOOK_UPDATE) + def update_phonebook(self): + """Update the phone book dictionary.""" + if not self.phonebook_id: + return + + self.phonebook_dict = self.fph.get_all_names(self.phonebook_id) + self.number_dict = { + re.sub(REGEX_NUMBER, "", nr): name + for name, nrs in self.phonebook_dict.items() + for nr in nrs + } + _LOGGER.info("Fritz!Box phone book successfully updated") + + def get_phonebook_ids(self): + """Return list of phonebook ids.""" + return self.fph.phonebook_ids + + def get_name(self, number): + """Return a name for a given phone number.""" + number = re.sub(REGEX_NUMBER, "", str(number)) + if self.number_dict is None: + return UNKOWN_NAME + + if number in self.number_dict: + return self.number_dict[number] + + if not self.prefixes: + return UNKOWN_NAME + + for prefix in self.prefixes: + try: + return self.number_dict[prefix + number] + except KeyError: + pass + try: + return self.number_dict[prefix + number.lstrip("0")] + except KeyError: + pass diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..ab296c84121ebfb0be080412ec2cfae9511a5481 --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -0,0 +1,265 @@ +"""Config flow for fritzbox_callmonitor.""" + +from fritzconnection import FritzConnection +from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError +from requests.exceptions import ConnectionError as RequestsConnectionError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.core import callback + +from .base import FritzBoxPhonebook + +# pylint:disable=unused-import +from .const import ( + CONF_PHONEBOOK, + CONF_PREFIXES, + DEFAULT_HOST, + DEFAULT_PHONEBOOK, + DEFAULT_PORT, + DEFAULT_USERNAME, + DOMAIN, + FRITZ_ACTION_GET_INFO, + FRITZ_ATTR_NAME, + FRITZ_ATTR_SERIAL_NUMBER, + FRITZ_SERVICE_DEVICE_INFO, + SERIAL_NUMBER, +) + +DATA_SCHEMA_USER = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + +RESULT_INVALID_AUTH = "invalid_auth" +RESULT_INSUFFICIENT_PERMISSIONS = "insufficient_permissions" +RESULT_MALFORMED_PREFIXES = "malformed_prefixes" +RESULT_NO_DEVIES_FOUND = "no_devices_found" +RESULT_SUCCESS = "success" + + +class FritzBoxCallMonitorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a fritzbox_callmonitor config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize flow.""" + self._host = None + self._port = None + self._username = None + self._password = None + self._phonebook_name = None + self._phonebook_names = None + self._phonebook_id = None + self._phonebook_ids = None + self._fritzbox_phonebook = None + self._prefixes = None + self._serial_number = None + + def _get_config_entry(self): + """Create and return an config entry.""" + return self.async_create_entry( + title=self._phonebook_name, + data={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + CONF_PHONEBOOK: self._phonebook_id, + CONF_PREFIXES: self._prefixes, + SERIAL_NUMBER: self._serial_number, + }, + ) + + def _try_connect(self): + """Try to connect and check auth.""" + self._fritzbox_phonebook = FritzBoxPhonebook( + host=self._host, + username=self._username, + password=self._password, + phonebook_id=self._phonebook_id, + prefixes=self._prefixes, + ) + + try: + self._fritzbox_phonebook.init_phonebook() + self._phonebook_ids = self._fritzbox_phonebook.get_phonebook_ids() + + fritz_connection = FritzConnection( + address=self._host, user=self._username, password=self._password + ) + device_info = fritz_connection.call_action( + FRITZ_SERVICE_DEVICE_INFO, FRITZ_ACTION_GET_INFO + ) + self._serial_number = device_info[FRITZ_ATTR_SERIAL_NUMBER] + + return RESULT_SUCCESS + except RequestsConnectionError: + return RESULT_NO_DEVIES_FOUND + except FritzSecurityError: + return RESULT_INSUFFICIENT_PERMISSIONS + except FritzConnectionException: + return RESULT_INVALID_AUTH + + async def _get_name_of_phonebook(self, phonebook_id): + """Return name of phonebook for given phonebook_id.""" + phonebook_info = await self.hass.async_add_executor_job( + self._fritzbox_phonebook.fph.phonebook_info, phonebook_id + ) + return phonebook_info[FRITZ_ATTR_NAME] + + async def _get_list_of_phonebook_names(self): + """Return list of names for all available phonebooks.""" + phonebook_names = [] + + for phonebook_id in self._phonebook_ids: + phonebook_names.append(await self._get_name_of_phonebook(phonebook_id)) + + return phonebook_names + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return FritzBoxCallMonitorOptionsFlowHandler(config_entry) + + async def async_step_import(self, user_input=None): + """Handle configuration by yaml file.""" + return await self.async_step_user(user_input) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA_USER, errors={} + ) + + self._host = user_input[CONF_HOST] + self._port = user_input[CONF_PORT] + self._password = user_input[CONF_PASSWORD] + self._username = user_input[CONF_USERNAME] + + result = await self.hass.async_add_executor_job(self._try_connect) + + if result == RESULT_INVALID_AUTH: + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA_USER, + errors={"base": RESULT_INVALID_AUTH}, + ) + + if result != RESULT_SUCCESS: + return self.async_abort(reason=result) + + if ( # pylint: disable=no-member + self.context["source"] == config_entries.SOURCE_IMPORT + ): + self._phonebook_id = user_input[CONF_PHONEBOOK] + self._phonebook_name = user_input[CONF_NAME] + + elif len(self._phonebook_ids) > 1: + return await self.async_step_phonebook() + + else: + self._phonebook_id = DEFAULT_PHONEBOOK + self._phonebook_name = await self._get_name_of_phonebook(self._phonebook_id) + + await self.async_set_unique_id(f"{self._serial_number}-{self._phonebook_id}") + self._abort_if_unique_id_configured() + + return self._get_config_entry() + + async def async_step_phonebook(self, user_input=None): + """Handle a flow to chose one of multiple available phonebooks.""" + + if self._phonebook_names is None: + self._phonebook_names = await self._get_list_of_phonebook_names() + + if user_input is None: + return self.async_show_form( + step_id="phonebook", + data_schema=vol.Schema( + {vol.Required(CONF_PHONEBOOK): vol.In(self._phonebook_names)} + ), + errors={}, + ) + + self._phonebook_name = user_input[CONF_PHONEBOOK] + self._phonebook_id = self._phonebook_names.index(self._phonebook_name) + + await self.async_set_unique_id(f"{self._serial_number}-{self._phonebook_id}") + self._abort_if_unique_id_configured() + + return self._get_config_entry() + + +class FritzBoxCallMonitorOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a fritzbox_callmonitor options flow.""" + + def __init__(self, config_entry): + """Initialize.""" + self.config_entry = config_entry + + @classmethod + def _are_prefixes_valid(cls, prefixes): + """Check if prefixes are valid.""" + return prefixes.strip() if prefixes else prefixes is None + + @classmethod + def _get_list_of_prefixes(cls, prefixes): + """Get list of prefixes.""" + if prefixes is None: + return None + return [prefix.strip() for prefix in prefixes.split(",")] + + def _get_option_schema_prefixes(self): + """Get option schema for entering prefixes.""" + return vol.Schema( + { + vol.Optional( + CONF_PREFIXES, + description={ + "suggested_value": self.config_entry.options.get(CONF_PREFIXES) + }, + ): str + } + ) + + async def async_step_init(self, user_input=None): + """Manage the options.""" + + option_schema_prefixes = self._get_option_schema_prefixes() + + if user_input is None: + return self.async_show_form( + step_id="init", + data_schema=option_schema_prefixes, + errors={}, + ) + + prefixes = user_input.get(CONF_PREFIXES) + + if not self._are_prefixes_valid(prefixes): + return self.async_show_form( + step_id="init", + data_schema=option_schema_prefixes, + errors={"base": RESULT_MALFORMED_PREFIXES}, + ) + + return self.async_create_entry( + title="", data={CONF_PREFIXES: self._get_list_of_prefixes(prefixes)} + ) diff --git a/homeassistant/components/fritzbox_callmonitor/const.py b/homeassistant/components/fritzbox_callmonitor/const.py new file mode 100644 index 0000000000000000000000000000000000000000..a71f14401b3cf2758b136f2f5bdd23669fb1eb3f --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/const.py @@ -0,0 +1,41 @@ +"""Constants for the AVM Fritz!Box call monitor integration.""" + +STATE_RINGING = "ringing" +STATE_DIALING = "dialing" +STATE_TALKING = "talking" +STATE_IDLE = "idle" + +FRITZ_STATE_RING = "RING" +FRITZ_STATE_CALL = "CALL" +FRITZ_STATE_CONNECT = "CONNECT" +FRITZ_STATE_DISCONNECT = "DISCONNECT" + +ICON_PHONE = "mdi:phone" + +ATTR_PREFIXES = "prefixes" + +FRITZ_ACTION_GET_INFO = "GetInfo" +FRITZ_ATTR_NAME = "name" +FRITZ_ATTR_SERIAL_NUMBER = "NewSerialNumber" +FRITZ_SERVICE_DEVICE_INFO = "DeviceInfo" + +UNKOWN_NAME = "unknown" +SERIAL_NUMBER = "serial_number" +REGEX_NUMBER = r"[^\d\+]" + +CONF_PHONEBOOK = "phonebook" +CONF_PHONEBOOK_NAME = "phonebook_name" +CONF_PREFIXES = "prefixes" + +DEFAULT_HOST = "169.254.1.1" # IP valid for all Fritz!Box routers +DEFAULT_PORT = 1012 +DEFAULT_USERNAME = "admin" +DEFAULT_PHONEBOOK = 0 +DEFAULT_NAME = "Phone" + +DOMAIN = "fritzbox_callmonitor" +MANUFACTURER = "AVM" + +PLATFORMS = ["sensor"] +UNDO_UPDATE_LISTENER = "undo_update_listener" +FRITZBOX_PHONEBOOK = "fritzbox_phonebook" diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index 4879842ee2249641926c146cfee3091714807a05..256292c88f7b27c24856d6b964392946e07f0872 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -1,6 +1,7 @@ { "domain": "fritzbox_callmonitor", "name": "AVM FRITZ!Box Call Monitor", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor", "requirements": ["fritzconnection==1.4.0"], "codeowners": [] diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 2656e07c3a507682429d8a4dea48699921a45d01..891bf8131d6faa67225768287dba7f09025216e6 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -1,15 +1,15 @@ """Sensor to monitor incoming/outgoing phone calls on a Fritz!Box router.""" -import datetime +from datetime import datetime, timedelta import logging -import re -import socket -import threading -import time +import queue +from threading import Event as ThreadingEvent, Thread +from time import sleep -from fritzconnection.lib.fritzphonebook import FritzPhonebook +from fritzconnection.core.fritzmonitor import FritzMonitor import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -20,97 +20,125 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) - -CONF_PHONEBOOK = "phonebook" -CONF_PREFIXES = "prefixes" - -DEFAULT_HOST = "169.254.1.1" # IP valid for all Fritz!Box routers -DEFAULT_USERNAME = "admin" -DEFAULT_NAME = "Phone" -DEFAULT_PORT = 1012 -DEFAULT_PHONEBOOK = 0 - -INTERVAL_RECONNECT = 60 +from .const import ( + ATTR_PREFIXES, + CONF_PHONEBOOK, + CONF_PREFIXES, + DEFAULT_HOST, + DEFAULT_NAME, + DEFAULT_PHONEBOOK, + DEFAULT_PORT, + DEFAULT_USERNAME, + DOMAIN, + FRITZ_STATE_CALL, + FRITZ_STATE_CONNECT, + FRITZ_STATE_DISCONNECT, + FRITZ_STATE_RING, + FRITZBOX_PHONEBOOK, + ICON_PHONE, + MANUFACTURER, + SERIAL_NUMBER, + STATE_DIALING, + STATE_IDLE, + STATE_RINGING, + STATE_TALKING, + UNKOWN_NAME, +) -VALUE_CALL = "dialing" -VALUE_CONNECT = "talking" -VALUE_DEFAULT = "idle" -VALUE_DISCONNECT = "idle" -VALUE_RING = "ringing" +_LOGGER = logging.getLogger(__name__) -# Return cached results if phonebook was downloaded less then this time ago. -MIN_TIME_PHONEBOOK_UPDATE = datetime.timedelta(hours=6) -SCAN_INTERVAL = datetime.timedelta(hours=3) +SCAN_INTERVAL = timedelta(hours=3) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PHONEBOOK, default=DEFAULT_PHONEBOOK): cv.positive_int, - vol.Optional(CONF_PREFIXES, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_PREFIXES): vol.All(cv.ensure_list, [cv.string]), } ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Fritz!Box call monitor sensor platform.""" - name = config[CONF_NAME] - host = config[CONF_HOST] - # Try to resolve a hostname; if it is already an IP, it will be returned as-is - try: - host = socket.gethostbyname(host) - except OSError: - _LOGGER.error("Could not resolve hostname %s", host) - return - port = config[CONF_PORT] - username = config[CONF_USERNAME] - password = config.get(CONF_PASSWORD) - phonebook_id = config[CONF_PHONEBOOK] - prefixes = config[CONF_PREFIXES] - - try: - phonebook = FritzBoxPhonebook( - host=host, - port=port, - username=username, - password=password, - phonebook_id=phonebook_id, - prefixes=prefixes, +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Import the platform into a config entry.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config ) - except: # noqa: E722 pylint: disable=bare-except - phonebook = None - _LOGGER.warning("Phonebook with ID %s not found on Fritz!Box", phonebook_id) + ) + - sensor = FritzBoxCallSensor(name=name, phonebook=phonebook) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the fritzbox_callmonitor sensor from config_entry.""" + fritzbox_phonebook = hass.data[DOMAIN][config_entry.entry_id][FRITZBOX_PHONEBOOK] - add_entities([sensor]) + phonebook_name = config_entry.title + phonebook_id = config_entry.data[CONF_PHONEBOOK] + prefixes = config_entry.options.get(CONF_PREFIXES) + serial_number = config_entry.data[SERIAL_NUMBER] + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] - monitor = FritzBoxCallMonitor(host=host, port=port, sensor=sensor) - monitor.connect() + name = f"{fritzbox_phonebook.fph.modelname} Call Monitor {phonebook_name}" + unique_id = f"{serial_number}-{phonebook_id}" - def _stop_listener(_event): - monitor.stopped.set() + sensor = FritzBoxCallSensor( + name=name, + unique_id=unique_id, + fritzbox_phonebook=fritzbox_phonebook, + prefixes=prefixes, + host=host, + port=port, + ) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_listener) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, sensor.async_will_remove_from_hass() + ) - return monitor.sock is not None + async_add_entities([sensor]) class FritzBoxCallSensor(Entity): """Implementation of a Fritz!Box call monitor.""" - def __init__(self, name, phonebook): + def __init__(self, name, unique_id, fritzbox_phonebook, prefixes, host, port): """Initialize the sensor.""" - self._state = VALUE_DEFAULT + self._state = STATE_IDLE self._attributes = {} - self._name = name - self.phonebook = phonebook + self._name = name.title() + self._unique_id = unique_id + self._fritzbox_phonebook = fritzbox_phonebook + self._prefixes = prefixes + self._host = host + self._port = port + self._monitor = None + + async def async_added_to_hass(self): + """Connect to FRITZ!Box to monitor its call state.""" + _LOGGER.debug("Starting monitor for: %s", self.entity_id) + self._monitor = FritzBoxCallMonitor( + host=self._host, + port=self._port, + sensor=self, + ) + self._monitor.connect() + + async def async_will_remove_from_hass(self): + """Disconnect from FRITZ!Box by stopping monitor.""" + if ( + self._monitor + and self._monitor.stopped + and not self._monitor.stopped.is_set() + and self._monitor.connection + and self._monitor.connection.is_alive + ): + self._monitor.stopped.set() + self._monitor.connection.stop() + _LOGGER.debug("Stopped monitor for: %s", self.entity_id) def set_state(self, state): """Set the state.""" @@ -120,10 +148,15 @@ class FritzBoxCallSensor(Entity): """Set the state attributes.""" self._attributes = attributes + @property + def name(self): + """Return name of this sensor.""" + return self._name + @property def should_poll(self): """Only poll to update phonebook, if defined.""" - return self.phonebook is not None + return self._fritzbox_phonebook is not None @property def state(self): @@ -131,25 +164,43 @@ class FritzBoxCallSensor(Entity): return self._state @property - def name(self): - """Return the name of the sensor.""" - return self._name + def icon(self): + """Return the icon of the sensor.""" + return ICON_PHONE @property def device_state_attributes(self): """Return the state attributes.""" + if self._prefixes: + self._attributes[ATTR_PREFIXES] = self._prefixes return self._attributes + @property + def device_info(self): + """Return device specific attributes.""" + return { + "name": self._fritzbox_phonebook.fph.modelname, + "identifiers": {(DOMAIN, self._unique_id)}, + "manufacturer": MANUFACTURER, + "model": self._fritzbox_phonebook.fph.modelname, + "sw_version": self._fritzbox_phonebook.fph.fc.system_version, + } + + @property + def unique_id(self): + """Return the unique ID of the device.""" + return self._unique_id + def number_to_name(self, number): """Return a name for a given phone number.""" - if self.phonebook is None: - return "unknown" - return self.phonebook.get_name(number) + if self._fritzbox_phonebook is None: + return UNKOWN_NAME + return self._fritzbox_phonebook.get_name(number) def update(self): """Update the phonebook if it is defined.""" - if self.phonebook is not None: - self.phonebook.update_phonebook() + if self._fritzbox_phonebook is not None: + self._fritzbox_phonebook.update_phonebook() class FritzBoxCallMonitor: @@ -159,142 +210,78 @@ class FritzBoxCallMonitor: """Initialize Fritz!Box monitor instance.""" self.host = host self.port = port - self.sock = None + self.connection = None + self.stopped = ThreadingEvent() self._sensor = sensor - self.stopped = threading.Event() def connect(self): """Connect to the Fritz!Box.""" - _LOGGER.debug("Setting up socket...") - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.settimeout(10) - self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + _LOGGER.debug("Setting up socket connection") try: - self.sock.connect((self.host, self.port)) - threading.Thread(target=self._listen).start() + self.connection = FritzMonitor(address=self.host, port=self.port) + kwargs = {"event_queue": self.connection.start()} + Thread(target=self._process_events, kwargs=kwargs).start() except OSError as err: - self.sock = None + self.connection = None _LOGGER.error( "Cannot connect to %s on port %s: %s", self.host, self.port, err ) - def _listen(self): + def _process_events(self, event_queue): """Listen to incoming or outgoing calls.""" - _LOGGER.debug("Connection established, waiting for response...") - while not self.stopped.isSet(): + _LOGGER.debug("Connection established, waiting for events") + while not self.stopped.is_set(): try: - response = self.sock.recv(2048) - except socket.timeout: - # if no response after 10 seconds, just recv again + event = event_queue.get(timeout=10) + except queue.Empty: + if not self.connection.is_alive and not self.stopped.is_set(): + _LOGGER.error("Connection has abruptly ended") + _LOGGER.debug("Empty event queue") continue - response = str(response, "utf-8") - _LOGGER.debug("Received %s", response) - - if not response: - # if the response is empty, the connection has been lost. - # try to reconnect - _LOGGER.warning("Connection lost, reconnecting...") - self.sock = None - while self.sock is None: - self.connect() - time.sleep(INTERVAL_RECONNECT) else: - line = response.split("\n", 1)[0] - self._parse(line) - time.sleep(1) + _LOGGER.debug("Received event: %s", event) + self._parse(event) + sleep(1) def _parse(self, line): """Parse the call information and set the sensor states.""" line = line.split(";") df_in = "%d.%m.%y %H:%M:%S" df_out = "%Y-%m-%dT%H:%M:%S" - isotime = datetime.datetime.strptime(line[0], df_in).strftime(df_out) - if line[1] == "RING": - self._sensor.set_state(VALUE_RING) + isotime = datetime.strptime(line[0], df_in).strftime(df_out) + if line[1] == FRITZ_STATE_RING: + self._sensor.set_state(STATE_RINGING) att = { "type": "incoming", "from": line[3], "to": line[4], "device": line[5], "initiated": isotime, + "from_name": self._sensor.number_to_name(line[3]), } - att["from_name"] = self._sensor.number_to_name(att["from"]) self._sensor.set_attributes(att) - elif line[1] == "CALL": - self._sensor.set_state(VALUE_CALL) + elif line[1] == FRITZ_STATE_CALL: + self._sensor.set_state(STATE_DIALING) att = { "type": "outgoing", "from": line[4], "to": line[5], "device": line[6], "initiated": isotime, + "to_name": self._sensor.number_to_name(line[5]), } - att["to_name"] = self._sensor.number_to_name(att["to"]) self._sensor.set_attributes(att) - elif line[1] == "CONNECT": - self._sensor.set_state(VALUE_CONNECT) - att = {"with": line[4], "device": line[3], "accepted": isotime} - att["with_name"] = self._sensor.number_to_name(att["with"]) + elif line[1] == FRITZ_STATE_CONNECT: + self._sensor.set_state(STATE_TALKING) + att = { + "with": line[4], + "device": line[3], + "accepted": isotime, + "with_name": self._sensor.number_to_name(line[4]), + } self._sensor.set_attributes(att) - elif line[1] == "DISCONNECT": - self._sensor.set_state(VALUE_DISCONNECT) + elif line[1] == FRITZ_STATE_DISCONNECT: + self._sensor.set_state(STATE_IDLE) att = {"duration": line[3], "closed": isotime} self._sensor.set_attributes(att) self._sensor.schedule_update_ha_state() - - -class FritzBoxPhonebook: - """This connects to a FritzBox router and downloads its phone book.""" - - def __init__(self, host, port, username, password, phonebook_id=0, prefixes=None): - """Initialize the class.""" - self.host = host - self.username = username - self.password = password - self.port = port - self.phonebook_id = phonebook_id - self.phonebook_dict = None - self.number_dict = None - self.prefixes = prefixes or [] - - # Establish a connection to the FRITZ!Box. - self.fph = FritzPhonebook( - address=self.host, user=self.username, password=self.password - ) - - if self.phonebook_id not in self.fph.list_phonebooks: - raise ValueError("Phonebook with this ID not found.") - - self.update_phonebook() - - @Throttle(MIN_TIME_PHONEBOOK_UPDATE) - def update_phonebook(self): - """Update the phone book dictionary.""" - self.phonebook_dict = self.fph.get_all_names(self.phonebook_id) - self.number_dict = { - re.sub(r"[^\d\+]", "", nr): name - for name, nrs in self.phonebook_dict.items() - for nr in nrs - } - _LOGGER.info("Fritz!Box phone book successfully updated") - - def get_name(self, number): - """Return a name for a given phone number.""" - number = re.sub(r"[^\d\+]", "", str(number)) - if self.number_dict is None: - return "unknown" - try: - return self.number_dict[number] - except KeyError: - pass - if self.prefixes: - for prefix in self.prefixes: - try: - return self.number_dict[prefix + number] - except KeyError: - pass - try: - return self.number_dict[prefix + number.lstrip("0")] - except KeyError: - pass - return "unknown" diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..d0325a07637e2ffada55716f772a48647a63aabf --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "flow_title": "AVM FRITZ!Box call monitor: {name}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "phonebook": { + "data": { + "phonebook": "Phonebook" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "insufficient_permissions": "User has insufficient permissions to access AVM FRITZ!Box settings and its phonebooks." + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + } + }, + "options": { + "step": { + "init": { + "title": "Configure Prefixes", + "data": { + "prefixes": "Prefixes (comma separated list)" + } + } + }, + "error": { + "malformed_prefixes": "Prefixes are malformed, please check their format." + } + } +} diff --git a/homeassistant/components/fritzbox_callmonitor/translations/en.json b/homeassistant/components/fritzbox_callmonitor/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..286bed1d5bc833c5bfc4ae126b5b58e212240738 --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/en.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "insufficient_permissions": "User has insufficient permissions to access AVM FRITZ!Box settings and its phonebooks.", + "no_devices_found": "No devices found on the network" + }, + "error": { + "invalid_auth": "Invalid authentication" + }, + "flow_title": "AVM FRITZ!Box call monitor: {name}", + "step": { + "phonebook": { + "data": { + "phonebook": "Phonebook" + } + }, + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "username": "Username" + } + } + } + }, + "options": { + "error": { + "malformed_prefixes": "Prefixes are malformed, please check their format." + }, + "step": { + "init": { + "data": { + "prefixes": "Prefixes (comma separated list)" + }, + "title": "Configure Prefixes" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fb9ec0f2e1e6ab33be9c2de1d0cecead2c7f556a..77a1dc91dd7aa57bde65cf459a39b2840f41c541 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -70,6 +70,7 @@ FLOWS = [ "foscam", "freebox", "fritzbox", + "fritzbox_callmonitor", "garmin_connect", "gdacs", "geofency", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 22653ffa0c9e971d03e4d9195ce3a82875bcf10d..c8da201de172cfb0d7fce3918a9590f040f90bc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -310,6 +310,11 @@ fnvhash==0.1.0 # homeassistant.components.foobot foobot_async==1.0.0 +# homeassistant.components.fritz +# homeassistant.components.fritzbox_callmonitor +# homeassistant.components.fritzbox_netmonitor +fritzconnection==1.4.0 + # homeassistant.components.google_translate gTTS==2.2.1 diff --git a/tests/components/fritzbox_callmonitor/__init__.py b/tests/components/fritzbox_callmonitor/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1afe7cb5eac92e1e7f4ae5d1c2b7663b57e89598 --- /dev/null +++ b/tests/components/fritzbox_callmonitor/__init__.py @@ -0,0 +1 @@ +"""Tests for fritzbox_callmonitor.""" diff --git a/tests/components/fritzbox_callmonitor/test_config_flow.py b/tests/components/fritzbox_callmonitor/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..00bc1e18679b4ccf30015b05cf5855c37f652527 --- /dev/null +++ b/tests/components/fritzbox_callmonitor/test_config_flow.py @@ -0,0 +1,358 @@ +"""Tests for fritzbox_callmonitor config flow.""" +from unittest.mock import PropertyMock + +from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.components.fritzbox_callmonitor.config_flow import ( + RESULT_INSUFFICIENT_PERMISSIONS, + RESULT_INVALID_AUTH, + RESULT_MALFORMED_PREFIXES, + RESULT_NO_DEVIES_FOUND, +) +from homeassistant.components.fritzbox_callmonitor.const import ( + CONF_PHONEBOOK, + CONF_PREFIXES, + DOMAIN, + FRITZ_ATTR_NAME, + FRITZ_ATTR_SERIAL_NUMBER, + SERIAL_NUMBER, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry, patch + +MOCK_HOST = "fake_host" +MOCK_PORT = 1234 +MOCK_USERNAME = "fake_username" +MOCK_PASSWORD = "fake_password" +MOCK_PHONEBOOK_NAME_1 = "fake_phonebook_name_1" +MOCK_PHONEBOOK_NAME_2 = "fake_phonebook_name_2" +MOCK_PHONEBOOK_ID = 0 +MOCK_SERIAL_NUMBER = "fake_serial_number" +MOCK_NAME = "fake_call_monitor_name" + +MOCK_USER_DATA = { + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_USERNAME: MOCK_USERNAME, +} +MOCK_CONFIG_ENTRY = { + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_USERNAME: MOCK_USERNAME, + CONF_PREFIXES: None, + CONF_PHONEBOOK: MOCK_PHONEBOOK_ID, + SERIAL_NUMBER: MOCK_SERIAL_NUMBER, +} +MOCK_YAML_CONFIG = { + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_USERNAME: MOCK_USERNAME, + CONF_PHONEBOOK: MOCK_PHONEBOOK_ID, + CONF_NAME: MOCK_NAME, +} +MOCK_DEVICE_INFO = {FRITZ_ATTR_SERIAL_NUMBER: MOCK_SERIAL_NUMBER} +MOCK_PHONEBOOK_INFO_1 = {FRITZ_ATTR_NAME: MOCK_PHONEBOOK_NAME_1} +MOCK_PHONEBOOK_INFO_2 = {FRITZ_ATTR_NAME: MOCK_PHONEBOOK_NAME_2} +MOCK_UNIQUE_ID = f"{MOCK_SERIAL_NUMBER}-{MOCK_PHONEBOOK_ID}" + + +async def test_yaml_import(hass: HomeAssistant) -> None: + """Test configuration.yaml import.""" + + with patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.__init__", + return_value=None, + ), patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.phonebook_ids", + new_callable=PropertyMock, + return_value=[0], + ), patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.phonebook_info", + return_value=MOCK_PHONEBOOK_INFO_1, + ), patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.modelname", + return_value=MOCK_PHONEBOOK_NAME_1, + ), patch( + "homeassistant.components.fritzbox_callmonitor.config_flow.FritzConnection.__init__", + return_value=None, + ), patch( + "homeassistant.components.fritzbox_callmonitor.config_flow.FritzConnection.call_action", + return_value=MOCK_DEVICE_INFO, + ), patch( + "homeassistant.components.fritzbox_callmonitor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_YAML_CONFIG, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_NAME + assert result["data"] == MOCK_CONFIG_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_setup_one_phonebook(hass: HomeAssistant) -> None: + """Test setting up manually.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.__init__", + return_value=None, + ), patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.phonebook_ids", + new_callable=PropertyMock, + return_value=[0], + ), patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.phonebook_info", + return_value=MOCK_PHONEBOOK_INFO_1, + ), patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.modelname", + return_value=MOCK_PHONEBOOK_NAME_1, + ), patch( + "homeassistant.components.fritzbox_callmonitor.config_flow.FritzConnection.__init__", + return_value=None, + ), patch( + "homeassistant.components.fritzbox_callmonitor.config_flow.FritzConnection.call_action", + return_value=MOCK_DEVICE_INFO, + ), patch( + "homeassistant.components.fritzbox_callmonitor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_PHONEBOOK_NAME_1 + assert result["data"] == MOCK_CONFIG_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_setup_multiple_phonebooks(hass: HomeAssistant) -> None: + """Test setting up manually.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.__init__", + return_value=None, + ), patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.phonebook_ids", + new_callable=PropertyMock, + return_value=[0, 1], + ), patch( + "homeassistant.components.fritzbox_callmonitor.config_flow.FritzConnection.__init__", + return_value=None, + ), patch( + "homeassistant.components.fritzbox_callmonitor.config_flow.FritzConnection.call_action", + return_value=MOCK_DEVICE_INFO, + ), patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.phonebook_info", + side_effect=[MOCK_PHONEBOOK_INFO_1, MOCK_PHONEBOOK_INFO_2], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "phonebook" + assert result["errors"] == {} + + with patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.modelname", + return_value=MOCK_PHONEBOOK_NAME_1, + ), patch( + "homeassistant.components.fritzbox_callmonitor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PHONEBOOK: MOCK_PHONEBOOK_NAME_2}, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_PHONEBOOK_NAME_2 + assert result["data"] == { + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_USERNAME: MOCK_USERNAME, + CONF_PREFIXES: None, + CONF_PHONEBOOK: 1, + SERIAL_NUMBER: MOCK_SERIAL_NUMBER, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_setup_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.__init__", + side_effect=RequestsConnectionError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == RESULT_NO_DEVIES_FOUND + + +async def test_setup_insufficient_permissions(hass: HomeAssistant) -> None: + """Test we handle insufficient permissions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.__init__", + side_effect=FritzSecurityError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == RESULT_INSUFFICIENT_PERMISSIONS + + +async def test_setup_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.__init__", + side_effect=FritzConnectionException, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": RESULT_INVALID_AUTH} + + +async def test_options_flow_correct_prefixes(hass: HomeAssistant) -> None: + """Test config flow options.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_UNIQUE_ID, + data=MOCK_CONFIG_ENTRY, + options={CONF_PREFIXES: None}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fritzbox_callmonitor.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_PREFIXES: "+49, 491234"} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_PREFIXES: ["+49", "491234"]} + + +async def test_options_flow_incorrect_prefixes(hass: HomeAssistant) -> None: + """Test config flow options.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_UNIQUE_ID, + data=MOCK_CONFIG_ENTRY, + options={CONF_PREFIXES: None}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fritzbox_callmonitor.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_PREFIXES: ""} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": RESULT_MALFORMED_PREFIXES} + + +async def test_options_flow_no_prefixes(hass: HomeAssistant) -> None: + """Test config flow options.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_UNIQUE_ID, + data=MOCK_CONFIG_ENTRY, + options={CONF_PREFIXES: None}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fritzbox_callmonitor.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_PREFIXES: None}