mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 01:08:12 +00:00
Add syncthing integration (#38331)
* Scaffold the integration * Add config flow data schema * Handle configuration errors * Get folder states * Support https * Fix translations * Listen to syncthing events in a separate thread * Bump syncthing * Automatically reconnect to the syncthing server * Renames * Improve loading and unloading * Update folder states from events * Refactoring, handle FolderPaused event * Dynamic folder icons * Refactoring * Mark folders as unavailable when senrver is unavailable * Update folder satus when server is available * Raise PlatformNotReady * Implement additional polling * Stop polling when the server is not available * Minor fixes * Remove logging * Check name uniqueness * Refactoring * Minor refactorings * Bump python-syncthing * Migrate to aiosyncthing * Minor fixes * Update .coveragerc * Set quality scale * Bump aiosyncthing, properly handle invalid token * Fix logging * Fix logging * Use CONF_VERIFY_SSL from homeassistant.const * Bump aiosyncthing. Add Syncthing device * Fix device name * Bump aiosyncthing * Bump aiosyncthing * Extract SyncthingClient * Add folder to device_state_attributes * Do not pass the loop * Cover config_flow.py * Move self.async_create_entry outside of the try block * Raise ConfigEntryNotReady if syncthing server is not reachable * Fix already configured error message * Change default name to Syncthing * Bump aiosyncthing * Fix formatting * Fix formatting * Fix tests * Fix typo, use lis comprehension * Fix typo, remove unused CONFIG_SCHEMA * Bump aiosyncthing * Remove periods from log messages W0001 * Fix tests * Black, isort * Remove empty items from manifest.json * Fix variable naming * Remove async_setup * Use SensorEntity * Use asyncio.create_task instead of self._hass.loop.create_task * Do not pass hass to FolderSensor initializer * Rename device_state_attributes to extra_state_attributes * Use callbacks * Simplify tests * Refactor _listen() * Use url for the title * Use the url instead of the name to identify the config entry * Explicitly set sensor attributes, extract _filter_state * Use server url instead of name in device_info * Use server url instead of name in logs * User server id as a device identifier * Use URL instead of name to identify config entry * Use shortened server id instead of name to build entity name and unique id * Do not use CONF_NAME * Cleanup unused strings * Cleanup unused strings * Add IOT class * Scaffold the integration * Add config flow data schema * Handle configuration errors * Get folder states * Support https * Fix translations * Listen to syncthing events in a separate thread * Bump syncthing * Automatically reconnect to the syncthing server * Renames * Improve loading and unloading * Update folder states from events * Refactoring, handle FolderPaused event * Dynamic folder icons * Refactoring * Mark folders as unavailable when senrver is unavailable * Update folder satus when server is available * Raise PlatformNotReady * Implement additional polling * Stop polling when the server is not available * Minor fixes * Remove logging * Check name uniqueness * Refactoring * Minor refactorings * Bump python-syncthing * Migrate to aiosyncthing * Minor fixes * Update .coveragerc * Set quality scale * Bump aiosyncthing, properly handle invalid token * Fix logging * Fix logging * Use CONF_VERIFY_SSL from homeassistant.const * Bump aiosyncthing. Add Syncthing device * Fix device name * Bump aiosyncthing * Bump aiosyncthing * Extract SyncthingClient * Add folder to device_state_attributes * Do not pass the loop * Cover config_flow.py * Move self.async_create_entry outside of the try block * Raise ConfigEntryNotReady if syncthing server is not reachable * Fix already configured error message * Change default name to Syncthing * Bump aiosyncthing * Fix formatting * Fix formatting * Fix tests * Fix typo, use lis comprehension * Fix typo, remove unused CONFIG_SCHEMA * Bump aiosyncthing * Remove periods from log messages W0001 * Fix tests * Black, isort * Remove empty items from manifest.json * Fix variable naming * Remove async_setup * Use SensorEntity * Use asyncio.create_task instead of self._hass.loop.create_task * Do not pass hass to FolderSensor initializer * Rename device_state_attributes to extra_state_attributes * Use callbacks * Simplify tests * Refactor _listen() * Use url for the title * Use the url instead of the name to identify the config entry * Explicitly set sensor attributes, extract _filter_state * Use server url instead of name in device_info * Use server url instead of name in logs * User server id as a device identifier * Use URL instead of name to identify config entry * Use shortened server id instead of name to build entity name and unique id * Do not use CONF_NAME * Cleanup unused strings * Cleanup unused strings * Add IOT class * Apply suggestions from code review * Clean up * Fix dict comprehension * Clean sensor * Use the server ID as a config entry unique ID * Remove the AlreadyConfigured exception * Clean up old error string * Format json * Convert sensor attributes to snake case * Force CI Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
7374b844d7
commit
97eb4c6c62
@ -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
|
||||
|
@ -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
|
||||
|
172
homeassistant/components/syncthing/__init__.py
Normal file
172
homeassistant/components/syncthing/__init__.py
Normal file
@ -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
|
72
homeassistant/components/syncthing/config_flow.py
Normal file
72
homeassistant/components/syncthing/config_flow.py
Normal file
@ -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."""
|
33
homeassistant/components/syncthing/const.py
Normal file
33
homeassistant/components/syncthing/const.py
Normal file
@ -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"
|
12
homeassistant/components/syncthing/manifest.json
Normal file
12
homeassistant/components/syncthing/manifest.json
Normal file
@ -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"
|
||||
}
|
268
homeassistant/components/syncthing/sensor.py
Normal file
268
homeassistant/components/syncthing/sensor.py
Normal file
@ -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
|
22
homeassistant/components/syncthing/strings.json
Normal file
22
homeassistant/components/syncthing/strings.json
Normal file
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
22
homeassistant/components/syncthing/translations/en.json
Normal file
22
homeassistant/components/syncthing/translations/en.json
Normal file
@ -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"
|
||||
}
|
@ -239,6 +239,7 @@ FLOWS = [
|
||||
"srp_energy",
|
||||
"starline",
|
||||
"subaru",
|
||||
"syncthing",
|
||||
"syncthru",
|
||||
"synology_dsm",
|
||||
"system_bridge",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/syncthing/__init__.py
Normal file
1
tests/components/syncthing/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the syncthing integration."""
|
106
tests/components/syncthing/test_config_flow.py
Normal file
106
tests/components/syncthing/test_config_flow.py
Normal file
@ -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"
|
Loading…
x
Reference in New Issue
Block a user