"""Support for TCP socket based sensors.""" from __future__ import annotations import logging import select import socket import ssl from typing import Any, Final import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, SensorEntity, ) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PAYLOAD, CONF_PORT, CONF_SSL, CONF_TIMEOUT, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from .const import ( CONF_BUFFER_SIZE, CONF_VALUE_ON, DEFAULT_BUFFER_SIZE, DEFAULT_NAME, DEFAULT_SSL, DEFAULT_TIMEOUT, DEFAULT_VERIFY_SSL, ) from .model import TcpSensorConfig _LOGGER: Final = logging.getLogger(__name__) PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port, vol.Required(CONF_PAYLOAD): cv.string, vol.Optional(CONF_BUFFER_SIZE, default=DEFAULT_BUFFER_SIZE): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_ON): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, } ) def setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: dict[str, Any] | None = None, ) -> None: """Set up the TCP Sensor.""" add_entities([TcpSensor(hass, config)]) class TcpSensor(SensorEntity): """Implementation of a TCP socket based sensor.""" def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: """Set all the config values if they exist and get initial state.""" value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass self._hass = hass self._config: TcpSensorConfig = { CONF_NAME: config[CONF_NAME], CONF_HOST: config[CONF_HOST], CONF_PORT: config[CONF_PORT], CONF_TIMEOUT: config[CONF_TIMEOUT], CONF_PAYLOAD: config[CONF_PAYLOAD], CONF_UNIT_OF_MEASUREMENT: config.get(CONF_UNIT_OF_MEASUREMENT), CONF_VALUE_TEMPLATE: value_template, CONF_VALUE_ON: config.get(CONF_VALUE_ON), CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], CONF_SSL: config[CONF_SSL], CONF_VERIFY_SSL: config[CONF_VERIFY_SSL], } self._ssl_context: ssl.SSLContext | None = None if self._config[CONF_SSL]: self._ssl_context = ssl.create_default_context() if not self._config[CONF_VERIFY_SSL]: self._ssl_context.check_hostname = False self._ssl_context.verify_mode = ssl.CERT_NONE self._state: str | None = None self.update() @property def name(self) -> str: """Return the name of this sensor.""" return self._config[CONF_NAME] @property def state(self) -> str | None: """Return the state of the device.""" return self._state @property def unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity.""" return self._config[CONF_UNIT_OF_MEASUREMENT] def update(self) -> None: """Get the latest value for this sensor.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(self._config[CONF_TIMEOUT]) try: sock.connect((self._config[CONF_HOST], self._config[CONF_PORT])) except OSError as err: _LOGGER.error( "Unable to connect to %s on port %s: %s", self._config[CONF_HOST], self._config[CONF_PORT], err, ) return if self._ssl_context is not None: sock = self._ssl_context.wrap_socket( sock, server_hostname=self._config[CONF_HOST] ) try: sock.send(self._config[CONF_PAYLOAD].encode()) except OSError as err: _LOGGER.error( "Unable to send payload %r to %s on port %s: %s", self._config[CONF_PAYLOAD], self._config[CONF_HOST], self._config[CONF_PORT], err, ) return readable, _, _ = select.select([sock], [], [], self._config[CONF_TIMEOUT]) if not readable: _LOGGER.warning( "Timeout (%s second(s)) waiting for a response after " "sending %r to %s on port %s", self._config[CONF_TIMEOUT], self._config[CONF_PAYLOAD], self._config[CONF_HOST], self._config[CONF_PORT], ) return value = sock.recv(self._config[CONF_BUFFER_SIZE]).decode() value_template = self._config[CONF_VALUE_TEMPLATE] if value_template is not None: try: self._state = value_template.render(parse_result=False, value=value) return except TemplateError: _LOGGER.error( "Unable to render template of %r with value: %r", self._config[CONF_VALUE_TEMPLATE], value, ) return self._state = value