diff --git a/.coveragerc b/.coveragerc index f86ea86d2d10207f60655d4b694aa53c254d6f94..1f532727427296836a3e11d46cc601609d980bd9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -967,6 +967,8 @@ omit = homeassistant/components/switchbot/switch.py homeassistant/components/switcher_kis/switch.py homeassistant/components/switchmate/switch.py + homeassistant/components/syncthing/__init__.py + homeassistant/components/syncthing/sensor.py homeassistant/components/syncthru/__init__.py homeassistant/components/syncthru/binary_sensor.py homeassistant/components/syncthru/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index eb46da1353da2668a12a82903e3124c20426a2ce..c2824fb33b63b14a38c040e7381633ad2b4ef8eb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -478,6 +478,7 @@ homeassistant/components/swiss_public_transport/* @fabaff homeassistant/components/switchbot/* @danielhiversen homeassistant/components/switcher_kis/* @tomerfi homeassistant/components/switchmate/* @danielhiversen +homeassistant/components/syncthing/* @zhulik homeassistant/components/syncthru/* @nielstron homeassistant/components/synology_dsm/* @hacf-fr @Quentame @mib1185 homeassistant/components/synology_srm/* @aerialls diff --git a/homeassistant/components/syncthing/__init__.py b/homeassistant/components/syncthing/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d7cc671465a78721d02c45bd4975db502154690d --- /dev/null +++ b/homeassistant/components/syncthing/__init__.py @@ -0,0 +1,172 @@ +"""The syncthing integration.""" +import asyncio +import logging + +import aiosyncthing + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_TOKEN, + CONF_URL, + CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + DOMAIN, + EVENTS, + RECONNECT_INTERVAL, + SERVER_AVAILABLE, + SERVER_UNAVAILABLE, +) + +PLATFORMS = ["sensor"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up syncthing from a config entry.""" + data = entry.data + + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + client = aiosyncthing.Syncthing( + data[CONF_TOKEN], + url=data[CONF_URL], + verify_ssl=data[CONF_VERIFY_SSL], + ) + + try: + status = await client.system.status() + except aiosyncthing.exceptions.SyncthingError as exception: + await client.close() + raise ConfigEntryNotReady from exception + + server_id = status["myID"] + + syncthing = SyncthingClient(hass, client, server_id) + syncthing.subscribe() + hass.data[DOMAIN][entry.entry_id] = syncthing + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + async def cancel_listen_task(_): + await syncthing.unsubscribe() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cancel_listen_task) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + syncthing = hass.data[DOMAIN].pop(entry.entry_id) + await syncthing.unsubscribe() + + return unload_ok + + +class SyncthingClient: + """A Syncthing client.""" + + def __init__(self, hass, client, server_id): + """Initialize the client.""" + self._hass = hass + self._client = client + self._server_id = server_id + self._listen_task = None + + @property + def server_id(self): + """Get server id.""" + return self._server_id + + @property + def url(self): + """Get server URL.""" + return self._client.url + + @property + def database(self): + """Get database namespace client.""" + return self._client.database + + @property + def system(self): + """Get system namespace client.""" + return self._client.system + + def subscribe(self): + """Start event listener coroutine.""" + self._listen_task = asyncio.create_task(self._listen()) + + async def unsubscribe(self): + """Stop event listener coroutine.""" + if self._listen_task: + self._listen_task.cancel() + await self._client.close() + + async def _listen(self): + """Listen to Syncthing events.""" + events = self._client.events + server_was_unavailable = False + while True: + if await self._server_available(): + if server_was_unavailable: + _LOGGER.info( + "The syncthing server '%s' is back online", self._client.url + ) + async_dispatcher_send( + self._hass, f"{SERVER_AVAILABLE}-{self._server_id}" + ) + server_was_unavailable = False + else: + await asyncio.sleep(RECONNECT_INTERVAL.total_seconds()) + continue + try: + async for event in events.listen(): + if events.last_seen_id == 0: + continue # skipping historical events from the first batch + if event["type"] not in EVENTS: + continue + + signal_name = EVENTS[event["type"]] + folder = None + if "folder" in event["data"]: + folder = event["data"]["folder"] + else: # A workaround, some events store folder id under `id` key + folder = event["data"]["id"] + async_dispatcher_send( + self._hass, + f"{signal_name}-{self._server_id}-{folder}", + event, + ) + except aiosyncthing.exceptions.SyncthingError: + _LOGGER.info( + "The syncthing server '%s' is not available. Sleeping %i seconds and retrying", + self._client.url, + RECONNECT_INTERVAL.total_seconds(), + ) + async_dispatcher_send( + self._hass, f"{SERVER_UNAVAILABLE}-{self._server_id}" + ) + await asyncio.sleep(RECONNECT_INTERVAL.total_seconds()) + server_was_unavailable = True + continue + + async def _server_available(self): + try: + await self._client.system.ping() + except aiosyncthing.exceptions.SyncthingError: + return False + else: + return True diff --git a/homeassistant/components/syncthing/config_flow.py b/homeassistant/components/syncthing/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..e6a5c994834817433f4a051b31a2f245361fa6e7 --- /dev/null +++ b/homeassistant/components/syncthing/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow for syncthing integration.""" +import logging + +import aiosyncthing +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL + +from .const import DEFAULT_URL, DEFAULT_VERIFY_SSL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): str, + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + + try: + async with aiosyncthing.Syncthing( + data[CONF_TOKEN], + url=data[CONF_URL], + verify_ssl=data[CONF_VERIFY_SSL], + loop=hass.loop, + ) as client: + server_id = (await client.system.status())["myID"] + return {"title": f"{data[CONF_URL]}", "server_id": server_id} + except aiosyncthing.exceptions.UnauthorizedError as error: + raise InvalidAuth from error + except Exception as error: + raise CannotConnect from error + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for syncthing.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors[CONF_TOKEN] = "invalid_auth" + else: + await self.async_set_unique_id(info["server_id"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/syncthing/const.py b/homeassistant/components/syncthing/const.py new file mode 100644 index 0000000000000000000000000000000000000000..a9ec0ad03759e8744ddc7d6b68350e8335506974 --- /dev/null +++ b/homeassistant/components/syncthing/const.py @@ -0,0 +1,33 @@ +"""Constants for the syncthing integration.""" +from datetime import timedelta + +DOMAIN = "syncthing" + +DEFAULT_VERIFY_SSL = True +DEFAULT_URL = "http://127.0.0.1:8384" + +RECONNECT_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=120) + +FOLDER_SUMMARY_RECEIVED = "syncthing_folder_summary_received" +FOLDER_PAUSED_RECEIVED = "syncthing_folder_paused_received" +SERVER_UNAVAILABLE = "syncthing_server_unavailable" +SERVER_AVAILABLE = "syncthing_server_available" +STATE_CHANGED_RECEIVED = "syncthing_state_changed_received" + +EVENTS = { + "FolderSummary": FOLDER_SUMMARY_RECEIVED, + "StateChanged": STATE_CHANGED_RECEIVED, + "FolderPaused": FOLDER_PAUSED_RECEIVED, +} + + +FOLDER_SENSOR_ICONS = { + "paused": "mdi:folder-clock", + "scanning": "mdi:folder-search", + "syncing": "mdi:folder-sync", + "idle": "mdi:folder", +} + +FOLDER_SENSOR_ALERT_ICON = "mdi:folder-alert" +FOLDER_SENSOR_DEFAULT_ICON = "mdi:folder" diff --git a/homeassistant/components/syncthing/manifest.json b/homeassistant/components/syncthing/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..cd779e1657b78955759d90221384e1350655e410 --- /dev/null +++ b/homeassistant/components/syncthing/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "syncthing", + "name": "Syncthing", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/syncthing", + "requirements": ["aiosyncthing==0.5.1"], + "codeowners": [ + "@zhulik" + ], + "quality_scale": "silver", + "iot_class": "local_polling" +} diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..5e8ea2f88c24271eca2a4a1ffad33148898b567e --- /dev/null +++ b/homeassistant/components/syncthing/sensor.py @@ -0,0 +1,268 @@ +"""Support for monitoring the Syncthing instance.""" + +import logging + +import aiosyncthing + +from homeassistant.components.sensor import SensorEntity +from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + DOMAIN, + FOLDER_PAUSED_RECEIVED, + FOLDER_SENSOR_ALERT_ICON, + FOLDER_SENSOR_DEFAULT_ICON, + FOLDER_SENSOR_ICONS, + FOLDER_SUMMARY_RECEIVED, + SCAN_INTERVAL, + SERVER_AVAILABLE, + SERVER_UNAVAILABLE, + STATE_CHANGED_RECEIVED, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Syncthing sensors.""" + syncthing = hass.data[DOMAIN][config_entry.entry_id] + + try: + config = await syncthing.system.config() + version = await syncthing.system.version() + except aiosyncthing.exceptions.SyncthingError as exception: + raise PlatformNotReady from exception + + server_id = syncthing.server_id + entities = [ + FolderSensor( + syncthing, + server_id, + folder["id"], + folder["label"], + version["version"], + ) + for folder in config["folders"] + ] + + async_add_entities(entities) + + +class FolderSensor(SensorEntity): + """A Syncthing folder sensor.""" + + STATE_ATTRIBUTES = { + "errors": "errors", + "globalBytes": "global_bytes", + "globalDeleted": "global_deleted", + "globalDirectories": "global_directories", + "globalFiles": "global_files", + "globalSymlinks": "global_symlinks", + "globalTotalItems": "global_total_items", + "ignorePatterns": "ignore_patterns", + "inSyncBytes": "in_sync_bytes", + "inSyncFiles": "in_sync_files", + "invalid": "invalid", + "localBytes": "local_bytes", + "localDeleted": "local_deleted", + "localDirectories": "local_directories", + "localFiles": "local_files", + "localSymlinks": "local_symlinks", + "localTotalItems": "local_total_items", + "needBytes": "need_bytes", + "needDeletes": "need_deletes", + "needDirectories": "need_directories", + "needFiles": "need_files", + "needSymlinks": "need_symlinks", + "needTotalItems": "need_total_items", + "pullErrors": "pull_errors", + "state": "state", + } + + def __init__(self, syncthing, server_id, folder_id, folder_label, version): + """Initialize the sensor.""" + self._syncthing = syncthing + self._server_id = server_id + self._folder_id = folder_id + self._folder_label = folder_label + self._state = None + self._unsub_timer = None + self._version = version + + self._short_server_id = server_id.split("-")[0] + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._short_server_id} {self._folder_id} {self._folder_label}" + + @property + def unique_id(self): + """Return the unique id of the entity.""" + return f"{self._short_server_id}-{self._folder_id}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state["state"] + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._state is not None + + @property + def icon(self): + """Return the icon for this sensor.""" + if self._state is None: + return FOLDER_SENSOR_DEFAULT_ICON + if self.state in FOLDER_SENSOR_ICONS: + return FOLDER_SENSOR_ICONS[self.state] + return FOLDER_SENSOR_ALERT_ICON + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return self._state + + @property + def should_poll(self): + """Return the polling requirement for this sensor.""" + return False + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._server_id)}, + "name": f"Syncthing ({self._syncthing.url})", + "manufacturer": "Syncthing Team", + "sw_version": self._version, + "entry_type": "service", + } + + async def async_update_status(self): + """Request folder status and update state.""" + try: + state = await self._syncthing.database.status(self._folder_id) + except aiosyncthing.exceptions.SyncthingError: + self._state = None + else: + self._state = self._filter_state(state) + self.async_write_ha_state() + + def subscribe(self): + """Start polling syncthing folder status.""" + if self._unsub_timer is None: + + async def refresh(event_time): + """Get the latest data from Syncthing.""" + await self.async_update_status() + + self._unsub_timer = async_track_time_interval( + self.hass, refresh, SCAN_INTERVAL + ) + + @callback + def unsubscribe(self): + """Stop polling syncthing folder status.""" + if self._unsub_timer is not None: + self._unsub_timer() + self._unsub_timer = None + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + + @callback + def handle_folder_summary(event): + if self._state is not None: + self._state = self._filter_state(event["data"]["summary"]) + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{FOLDER_SUMMARY_RECEIVED}-{self._server_id}-{self._folder_id}", + handle_folder_summary, + ) + ) + + @callback + def handle_state_changed(event): + if self._state is not None: + self._state["state"] = event["data"]["to"] + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{STATE_CHANGED_RECEIVED}-{self._server_id}-{self._folder_id}", + handle_state_changed, + ) + ) + + @callback + def handle_folder_paused(event): + if self._state is not None: + self._state["state"] = "paused" + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{FOLDER_PAUSED_RECEIVED}-{self._server_id}-{self._folder_id}", + handle_folder_paused, + ) + ) + + @callback + def handle_server_unavailable(): + self._state = None + self.unsubscribe() + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._server_id}", + handle_server_unavailable, + ) + ) + + async def handle_server_available(): + self.subscribe() + await self.async_update_status() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_AVAILABLE}-{self._server_id}", + handle_server_available, + ) + ) + + self.subscribe() + self.async_on_remove(self.unsubscribe) + + await self.async_update_status() + + def _filter_state(self, state): + # Select only needed state attributes and map their names + state = { + self.STATE_ATTRIBUTES[key]: value + for key, value in state.items() + if key in self.STATE_ATTRIBUTES + } + + # A workaround, for some reason, state of paused folders is an empty string + if state["state"] == "": + state["state"] = "paused" + + # Add some useful attributes + state["id"] = self._folder_id + state["label"] = self._folder_label + + return state diff --git a/homeassistant/components/syncthing/strings.json b/homeassistant/components/syncthing/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..1781df56f1e778eaae7fab8df1684a632315ee6b --- /dev/null +++ b/homeassistant/components/syncthing/strings.json @@ -0,0 +1,22 @@ +{ + "title": "Syncthing", + "config": { + "step": { + "user": { + "data": { + "title": "Setup Syncthing integration", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "token": "Token" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/syncthing/translations/en.json b/homeassistant/components/syncthing/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..00c73bedb9e62ab4e5a2a7b6c689cf7415334517 --- /dev/null +++ b/homeassistant/components/syncthing/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "Unable to connect to the Syncthing server.", + "invalid_auth": "Invalid authentication" + }, + "abort": { + "already_configured": "Service is already configured" + }, + "step": { + "user": { + "title": "Setup Syncthing integration", + "data": { + "url": "URL", + "token": "Token", + "verify_ssl": "Verify SSL" + } + } + } + }, + "title": "Syncthing" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6adcb16cc15a0c9bc9cbaf0debb337a2aba3bd1a..bb346ff5b0f9b78df899dfc5380b76a43e59092b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -239,6 +239,7 @@ FLOWS = [ "srp_energy", "starline", "subaru", + "syncthing", "syncthru", "synology_dsm", "system_bridge", diff --git a/requirements_all.txt b/requirements_all.txt index 39305ff7ccb7d4055c16b9e4ca9d248c45939cb8..481763adcc75b938141b4c735e92599d32dcc863 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -232,6 +232,9 @@ aioshelly==0.6.2 # homeassistant.components.switcher_kis aioswitcher==1.2.1 +# homeassistant.components.syncthing +aiosyncthing==0.5.1 + # homeassistant.components.unifi aiounifi==26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 97ee8a487e1ee9352bacbbc48facec120a0c528d..883ece77c4e1a695519c19fed9bde505c778b5b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -154,6 +154,9 @@ aioshelly==0.6.2 # homeassistant.components.switcher_kis aioswitcher==1.2.1 +# homeassistant.components.syncthing +aiosyncthing==0.5.1 + # homeassistant.components.unifi aiounifi==26 diff --git a/tests/components/syncthing/__init__.py b/tests/components/syncthing/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8a4f28832ea37001368a4b3467df2a431fcccd3c --- /dev/null +++ b/tests/components/syncthing/__init__.py @@ -0,0 +1 @@ +"""Tests for the syncthing integration.""" diff --git a/tests/components/syncthing/test_config_flow.py b/tests/components/syncthing/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..30f8bc0386b032aabbf644cedfaf1bf3832475c9 --- /dev/null +++ b/tests/components/syncthing/test_config_flow.py @@ -0,0 +1,106 @@ +"""Tests for syncthing config flow.""" + +from unittest.mock import patch + +from aiosyncthing.exceptions import UnauthorizedError + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.syncthing.const import DOMAIN +from homeassistant.const import CONF_NAME, CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL + +from tests.common import MockConfigEntry + +NAME = "Syncthing" +URL = "http://127.0.0.1:8384" +TOKEN = "token" +VERIFY_SSL = True + +MOCK_ENTRY = { + CONF_NAME: NAME, + CONF_URL: URL, + CONF_TOKEN: TOKEN, + CONF_VERIFY_SSL: VERIFY_SSL, +} + + +async def test_show_setup_form(hass): + """Test that the setup form is served.""" + 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"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + +async def test_flow_successfull(hass): + """Test with required fields only.""" + with patch( + "aiosyncthing.system.System.status", return_value={"myID": "server-id"} + ), patch( + "homeassistant.components.syncthing.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "user"}, + data={ + CONF_NAME: NAME, + CONF_URL: URL, + CONF_TOKEN: TOKEN, + CONF_VERIFY_SSL: VERIFY_SSL, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "http://127.0.0.1:8384" + assert result["data"][CONF_NAME] == NAME + assert result["data"][CONF_URL] == URL + assert result["data"][CONF_TOKEN] == TOKEN + assert result["data"][CONF_VERIFY_SSL] == VERIFY_SSL + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_already_configured(hass): + """Test name is already configured.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY, unique_id="server-id") + entry.add_to_hass(hass) + + with patch("aiosyncthing.system.System.status", return_value={"myID": "server-id"}): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "user"}, + data=MOCK_ENTRY, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_invalid_auth(hass): + """Test invalid auth.""" + + with patch("aiosyncthing.system.System.status", side_effect=UnauthorizedError): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "user"}, + data=MOCK_ENTRY, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"]["token"] == "invalid_auth" + + +async def test_flow_cannot_connect(hass): + """Test cannot connect.""" + + with patch("aiosyncthing.system.System.status", side_effect=Exception): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "user"}, + data=MOCK_ENTRY, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"]["base"] == "cannot_connect"