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
14 changed files with 718 additions and 0 deletions
|
@ -967,6 +967,8 @@ omit =
|
||||||
homeassistant/components/switchbot/switch.py
|
homeassistant/components/switchbot/switch.py
|
||||||
homeassistant/components/switcher_kis/switch.py
|
homeassistant/components/switcher_kis/switch.py
|
||||||
homeassistant/components/switchmate/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/__init__.py
|
||||||
homeassistant/components/syncthru/binary_sensor.py
|
homeassistant/components/syncthru/binary_sensor.py
|
||||||
homeassistant/components/syncthru/sensor.py
|
homeassistant/components/syncthru/sensor.py
|
||||||
|
|
|
@ -478,6 +478,7 @@ homeassistant/components/swiss_public_transport/* @fabaff
|
||||||
homeassistant/components/switchbot/* @danielhiversen
|
homeassistant/components/switchbot/* @danielhiversen
|
||||||
homeassistant/components/switcher_kis/* @tomerfi
|
homeassistant/components/switcher_kis/* @tomerfi
|
||||||
homeassistant/components/switchmate/* @danielhiversen
|
homeassistant/components/switchmate/* @danielhiversen
|
||||||
|
homeassistant/components/syncthing/* @zhulik
|
||||||
homeassistant/components/syncthru/* @nielstron
|
homeassistant/components/syncthru/* @nielstron
|
||||||
homeassistant/components/synology_dsm/* @hacf-fr @Quentame @mib1185
|
homeassistant/components/synology_dsm/* @hacf-fr @Quentame @mib1185
|
||||||
homeassistant/components/synology_srm/* @aerialls
|
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",
|
"srp_energy",
|
||||||
"starline",
|
"starline",
|
||||||
"subaru",
|
"subaru",
|
||||||
|
"syncthing",
|
||||||
"syncthru",
|
"syncthru",
|
||||||
"synology_dsm",
|
"synology_dsm",
|
||||||
"system_bridge",
|
"system_bridge",
|
||||||
|
|
|
@ -232,6 +232,9 @@ aioshelly==0.6.2
|
||||||
# homeassistant.components.switcher_kis
|
# homeassistant.components.switcher_kis
|
||||||
aioswitcher==1.2.1
|
aioswitcher==1.2.1
|
||||||
|
|
||||||
|
# homeassistant.components.syncthing
|
||||||
|
aiosyncthing==0.5.1
|
||||||
|
|
||||||
# homeassistant.components.unifi
|
# homeassistant.components.unifi
|
||||||
aiounifi==26
|
aiounifi==26
|
||||||
|
|
||||||
|
|
|
@ -154,6 +154,9 @@ aioshelly==0.6.2
|
||||||
# homeassistant.components.switcher_kis
|
# homeassistant.components.switcher_kis
|
||||||
aioswitcher==1.2.1
|
aioswitcher==1.2.1
|
||||||
|
|
||||||
|
# homeassistant.components.syncthing
|
||||||
|
aiosyncthing==0.5.1
|
||||||
|
|
||||||
# homeassistant.components.unifi
|
# homeassistant.components.unifi
|
||||||
aiounifi==26
|
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…
Add table
Add a link
Reference in a new issue