diff --git a/.coveragerc b/.coveragerc index d692794c6e4..6c210d986bc 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/syncthru/__init__.py + homeassistant/components/syncthru/binary_sensor.py homeassistant/components/syncthru/sensor.py homeassistant/components/synology_chat/notify.py homeassistant/components/synology_dsm/__init__.py diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index a5dbd2a9a35..5c28e6f029a 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -1,22 +1,26 @@ """The syncthru component.""" from __future__ import annotations +from datetime import timedelta import logging +import async_timeout from pysyncthru import SyncThru +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [SENSOR_DOMAIN] +PLATFORMS = [BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -24,21 +28,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = aiohttp_client.async_get_clientsession(hass) hass.data.setdefault(DOMAIN, {}) - printer = hass.data[DOMAIN][entry.entry_id] = SyncThru( - entry.data[CONF_URL], session - ) + printer = SyncThru(entry.data[CONF_URL], session) - try: - await printer.update() - except ValueError: - _LOGGER.error( - "Device at %s not appear to be a SyncThru printer, aborting setup", - printer.url, - ) - return False - else: - if printer.is_unknown_state(): - raise ConfigEntryNotReady + async def async_update_data() -> SyncThru: + """Fetch data from the printer.""" + try: + async with async_timeout.timeout(10): + await printer.update() + except ValueError as value_error: + # if an exception is thrown, printer does not support syncthru + raise UpdateFailed( + f"Configured printer at {printer.url} does not respond. " + "Please make sure it supports SyncThru and check your configuration." + ) from value_error + else: + if printer.is_unknown_state(): + raise ConfigEntryNotReady + return printer + + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=async_update_data, + update_interval=timedelta(seconds=30), + ) + hass.data[DOMAIN][entry.entry_id] = coordinator + await coordinator.async_config_entry_first_refresh() device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( @@ -60,9 +76,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -def device_identifiers(printer: SyncThru) -> set[tuple[str, ...]]: +def device_identifiers(printer: SyncThru) -> set[tuple[str, ...]] | None: """Get device identifiers for device registry.""" - return {(DOMAIN, printer.serial_number())} + serial = printer.serial_number() + if serial is None: + return None + return {(DOMAIN, serial)} def device_connections(printer: SyncThru) -> set[tuple[str, str]]: diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py new file mode 100644 index 00000000000..66bf76b31a5 --- /dev/null +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -0,0 +1,110 @@ +"""Support for Samsung Printers with SyncThru web interface.""" + +import logging + +from pysyncthru import SyncThru, SyncthruState + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_PROBLEM, + BinarySensorEntity, +) +from homeassistant.const import CONF_NAME +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import device_identifiers +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SYNCTHRU_STATE_PROBLEM = { + SyncthruState.INVALID: True, + SyncthruState.OFFLINE: None, + SyncthruState.NORMAL: False, + SyncthruState.UNKNOWN: True, + SyncthruState.WARNING: True, + SyncthruState.TESTING: False, + SyncthruState.ERROR: True, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up from config entry.""" + + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + name = config_entry.data[CONF_NAME] + entities = [ + SyncThruOnlineSensor(coordinator, name), + SyncThruProblemSensor(coordinator, name), + ] + + async_add_entities(entities) + + +class SyncThruBinarySensor(CoordinatorEntity, BinarySensorEntity): + """Implementation of an abstract Samsung Printer binary sensor platform.""" + + def __init__(self, coordinator, name): + """Initialize the sensor.""" + super().__init__(coordinator) + self.syncthru: SyncThru = coordinator.data + self._name = name + self._id_suffix = "" + + @property + def unique_id(self): + """Return unique ID for the sensor.""" + serial = self.syncthru.serial_number() + return f"{serial}{self._id_suffix}" if serial else None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_info(self): + """Return device information.""" + return {"identifiers": device_identifiers(self.syncthru)} + + +class SyncThruOnlineSensor(SyncThruBinarySensor): + """Implementation of a sensor that checks whether is turned on/online.""" + + def __init__(self, syncthru, name): + """Initialize the sensor.""" + super().__init__(syncthru, name) + self._id_suffix = "_online" + + @property + def device_class(self): + """Class of the sensor.""" + return DEVICE_CLASS_CONNECTIVITY + + @property + def is_on(self): + """Set the state to whether the printer is online.""" + return self.syncthru.is_online() + + +class SyncThruProblemSensor(SyncThruBinarySensor): + """Implementation of a sensor that checks whether the printer works correctly.""" + + def __init__(self, syncthru, name): + """Initialize the sensor.""" + super().__init__(syncthru, name) + self._id_suffix = "_problem" + + @property + def device_class(self): + """Class of the sensor.""" + return DEVICE_CLASS_PROBLEM + + @property + def is_on(self): + """Set the state to whether there is a problem with the printer.""" + return SYNCTHRU_STATE_PROBLEM[self.syncthru.device_status()] diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index e84a52b514e..9fd3c2afe06 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -3,7 +3,7 @@ "name": "Samsung SyncThru Printer", "documentation": "https://www.home-assistant.io/integrations/syncthru", "config_flow": true, - "requirements": ["pysyncthru==0.7.0", "url-normalize==1.4.1"], + "requirements": ["pysyncthru==0.7.3", "url-normalize==1.4.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 8277bd69467..2b559e0a15f 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -2,13 +2,17 @@ import logging -from pysyncthru import SYNCTHRU_STATE_HUMAN, SyncThru +from pysyncthru import SyncThru, SyncthruState import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_RESOURCE, CONF_URL, PERCENTAGE import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import device_identifiers from .const import DEFAULT_MODEL, DEFAULT_NAME_TEMPLATE, DOMAIN @@ -26,6 +30,16 @@ DEFAULT_MONITORED_CONDITIONS.extend([f"drum_{key}" for key in DRUM_COLORS]) DEFAULT_MONITORED_CONDITIONS.extend([f"tray_{key}" for key in TRAYS]) DEFAULT_MONITORED_CONDITIONS.extend([f"output_tray_{key}" for key in OUTPUT_TRAYS]) +SYNCTHRU_STATE_HUMAN = { + SyncthruState.INVALID: "invalid", + SyncthruState.OFFLINE: "unreachable", + SyncthruState.NORMAL: "normal", + SyncthruState.UNKNOWN: "unknown", + SyncthruState.WARNING: "warning", + SyncthruState.TESTING: "testing", + SyncthruState.ERROR: "error", +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_RESOURCE): cv.url, @@ -58,7 +72,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up from config entry.""" - printer = hass.data[DOMAIN][config_entry.entry_id] + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + printer: SyncThru = coordinator.data supp_toner = printer.toner_status(filter_supported=True) supp_drum = printer.drum_status(filter_supported=True) @@ -66,28 +81,27 @@ async def async_setup_entry(hass, config_entry, async_add_entities): supp_output_tray = printer.output_tray_status() name = config_entry.data[CONF_NAME] - devices = [SyncThruMainSensor(printer, name)] + entities = [SyncThruMainSensor(coordinator, name)] for key in supp_toner: - devices.append(SyncThruTonerSensor(printer, name, key)) + entities.append(SyncThruTonerSensor(coordinator, name, key)) for key in supp_drum: - devices.append(SyncThruDrumSensor(printer, name, key)) + entities.append(SyncThruDrumSensor(coordinator, name, key)) for key in supp_tray: - devices.append(SyncThruInputTraySensor(printer, name, key)) + entities.append(SyncThruInputTraySensor(coordinator, name, key)) for key in supp_output_tray: - devices.append(SyncThruOutputTraySensor(printer, name, key)) + entities.append(SyncThruOutputTraySensor(coordinator, name, key)) - async_add_entities(devices, True) + async_add_entities(entities) -class SyncThruSensor(SensorEntity): +class SyncThruSensor(CoordinatorEntity, SensorEntity): """Implementation of an abstract Samsung Printer sensor platform.""" - def __init__(self, syncthru, name): + def __init__(self, coordinator, name): """Initialize the sensor.""" - self.syncthru: SyncThru = syncthru - self._attributes = {} - self._state = None + super().__init__(coordinator) + self.syncthru: SyncThru = coordinator.data self._name = name self._icon = "mdi:printer" self._unit_of_measurement = None @@ -97,18 +111,13 @@ class SyncThruSensor(SensorEntity): def unique_id(self): """Return unique ID for the sensor.""" serial = self.syncthru.serial_number() - return serial + self._id_suffix if serial else super().unique_id + return f"{serial}{self._id_suffix}" if serial else None @property def name(self): """Return the name of the sensor.""" return self._name - @property - def state(self): - """Return the state of the device.""" - return self._state - @property def icon(self): """Return the icon of the device.""" @@ -119,11 +128,6 @@ class SyncThruSensor(SensorEntity): """Return the unit of measuremnt.""" return self._unit_of_measurement - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - return self._attributes - @property def device_info(self): """Return device information.""" @@ -131,50 +135,56 @@ class SyncThruSensor(SensorEntity): class SyncThruMainSensor(SyncThruSensor): - """Implementation of the main sensor, conducting the actual polling.""" + """ + Implementation of the main sensor, conducting the actual polling. - def __init__(self, syncthru, name): + It also shows the detailed state and presents + the displayed current status message. + """ + + def __init__(self, coordinator, name): """Initialize the sensor.""" - super().__init__(syncthru, name) + super().__init__(coordinator, name) self._id_suffix = "_main" - self._active = True - async def async_update(self): - """Get the latest data from SyncThru and update the state.""" - if not self._active: - return - try: - await self.syncthru.update() - except ValueError: - # if an exception is thrown, printer does not support syncthru - _LOGGER.warning( - "Configured printer at %s does not support SyncThru. " - "Consider changing your configuration", - self.syncthru.url, - ) - self._active = False - self._state = SYNCTHRU_STATE_HUMAN[self.syncthru.device_status()] - self._attributes = {"display_text": self.syncthru.device_status_details()} + @property + def state(self): + """Set state to human readable version of syncthru status.""" + return SYNCTHRU_STATE_HUMAN[self.syncthru.device_status()] + + @property + def extra_state_attributes(self): + """Show current printer display text.""" + return { + "display_text": self.syncthru.device_status_details(), + } + + @property + def entity_registry_enabled_default(self) -> bool: + """Disable entity by default.""" + return False class SyncThruTonerSensor(SyncThruSensor): """Implementation of a Samsung Printer toner sensor platform.""" - def __init__(self, syncthru, name, color): + def __init__(self, coordinator, name, color): """Initialize the sensor.""" - super().__init__(syncthru, name) + super().__init__(coordinator, name) self._name = f"{name} Toner {color}" self._color = color self._unit_of_measurement = PERCENTAGE self._id_suffix = f"_toner_{color}" - def update(self): - """Get the latest data from SyncThru and update the state.""" - # Data fetching is taken care of through the Main sensor + @property + def extra_state_attributes(self): + """Show all data returned for this toner.""" + return self.syncthru.toner_status().get(self._color, {}) - if self.syncthru.is_online(): - self._attributes = self.syncthru.toner_status().get(self._color, {}) - self._state = self._attributes.get("remaining") + @property + def state(self): + """Show amount of remaining toner.""" + return self.syncthru.toner_status().get(self._color, {}).get("remaining") class SyncThruDrumSensor(SyncThruSensor): @@ -188,13 +198,15 @@ class SyncThruDrumSensor(SyncThruSensor): self._unit_of_measurement = PERCENTAGE self._id_suffix = f"_drum_{color}" - def update(self): - """Get the latest data from SyncThru and update the state.""" - # Data fetching is taken care of through the Main sensor + @property + def extra_state_attributes(self): + """Show all data returned for this drum.""" + return self.syncthru.drum_status().get(self._color, {}) - if self.syncthru.is_online(): - self._attributes = self.syncthru.drum_status().get(self._color, {}) - self._state = self._attributes.get("remaining") + @property + def state(self): + """Show amount of remaining drum.""" + return self.syncthru.drum_status().get(self._color, {}).get("remaining") class SyncThruInputTraySensor(SyncThruSensor): @@ -207,15 +219,20 @@ class SyncThruInputTraySensor(SyncThruSensor): self._number = number self._id_suffix = f"_tray_{number}" - def update(self): - """Get the latest data from SyncThru and update the state.""" - # Data fetching is taken care of through the Main sensor + @property + def extra_state_attributes(self): + """Show all data returned for this input tray.""" + return self.syncthru.input_tray_status().get(self._number, {}) - if self.syncthru.is_online(): - self._attributes = self.syncthru.input_tray_status().get(self._number, {}) - self._state = self._attributes.get("newError") - if self._state == "": - self._state = "Ready" + @property + def state(self): + """Display ready unless there is some error, then display error.""" + tray_state = ( + self.syncthru.input_tray_status().get(self._number, {}).get("newError") + ) + if tray_state == "": + tray_state = "Ready" + return tray_state class SyncThruOutputTraySensor(SyncThruSensor): @@ -228,12 +245,17 @@ class SyncThruOutputTraySensor(SyncThruSensor): self._number = number self._id_suffix = f"_output_tray_{number}" - def update(self): - """Get the latest data from SyncThru and update the state.""" - # Data fetching is taken care of through the Main sensor + @property + def extra_state_attributes(self): + """Show all data returned for this output tray.""" + return self.syncthru.output_tray_status().get(self._number, {}) - if self.syncthru.is_online(): - self._attributes = self.syncthru.output_tray_status().get(self._number, {}) - self._state = self._attributes.get("status") - if self._state == "": - self._state = "Ready" + @property + def state(self): + """Display ready unless there is some error, then display error.""" + tray_state = ( + self.syncthru.output_tray_status().get(self._number, {}).get("status") + ) + if tray_state == "": + tray_state = "Ready" + return tray_state diff --git a/requirements_all.txt b/requirements_all.txt index 75c409cb11b..c377e04a40b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1756,7 +1756,7 @@ pystiebeleltron==0.0.1.dev2 pysuez==0.1.19 # homeassistant.components.syncthru -pysyncthru==0.7.0 +pysyncthru==0.7.3 # homeassistant.components.tankerkoenig pytankerkoenig==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7989e6cfbf9..3a879156951 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -968,7 +968,7 @@ pyspcwebgw==0.4.0 pysqueezebox==0.5.5 # homeassistant.components.syncthru -pysyncthru==0.7.0 +pysyncthru==0.7.3 # homeassistant.components.ecobee python-ecobee-api==0.2.11