Gleb Sinyavskiy 97eb4c6c62
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>
2021-05-08 19:12:14 +02:00

269 lines
8.0 KiB
Python

"""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