Skip to content

Commit

Permalink
Add syncthing integration (home-assistant#38331)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
zhulik and MartinHjelmare authored May 8, 2021
1 parent 7374b84 commit 97eb4c6
Show file tree
Hide file tree
Showing 14 changed files with 718 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
172 changes: 172 additions & 0 deletions homeassistant/components/syncthing/__init__.py
Original file line number Diff line number Diff line change
@@ -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 changes: 72 additions & 0 deletions homeassistant/components/syncthing/config_flow.py
Original file line number Diff line number Diff line change
@@ -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 changes: 33 additions & 0 deletions homeassistant/components/syncthing/const.py
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions homeassistant/components/syncthing/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading

0 comments on commit 97eb4c6

Please sign in to comment.