diff --git a/CODEOWNERS b/CODEOWNERS index 87284e36df39c..92991e90b8df3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -629,6 +629,8 @@ build.json @home-assistant/supervisor /tests/components/monoprice/ @etsinko @OnFreund /homeassistant/components/moon/ @fabaff @frenck /tests/components/moon/ @fabaff @frenck +/homeassistant/components/moonraker/ @cmroche +/tests/components/moonraker/ @cmroche /homeassistant/components/motion_blinds/ @starkillerOG /tests/components/motion_blinds/ @starkillerOG /homeassistant/components/motioneye/ @dermotduffy diff --git a/homeassistant/components/moonraker/__init__.py b/homeassistant/components/moonraker/__init__.py new file mode 100644 index 0000000000000..1b9e493e9f418 --- /dev/null +++ b/homeassistant/components/moonraker/__init__.py @@ -0,0 +1,38 @@ +"""The moonraker integration.""" +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .connector import APIConnector +from .const import DATA_CONNECTOR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up moonraker from a config entry.""" + session = async_get_clientsession(hass) + connector = APIConnector(hass, session, entry) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_CONNECTOR: connector} + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + await connector.start() + 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: + data = hass.data[DOMAIN].pop(entry.entry_id) + if connector := data.get(DATA_CONNECTOR): + await connector.stop() + + return unload_ok diff --git a/homeassistant/components/moonraker/config_flow.py b/homeassistant/components/moonraker/config_flow.py new file mode 100644 index 0000000000000..7d94ebbfc3675 --- /dev/null +++ b/homeassistant/components/moonraker/config_flow.py @@ -0,0 +1,211 @@ +"""Config flow for moonraker integration.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from aiohttp import ClientSession +from moonraker_api import ClientNotAuthenticatedError, MoonrakerClient +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def _schema_with_defaults(host="", port=7125, ssl=False, api_key=""): + return vol.Schema( + { + vol.Required(CONF_HOST, default=host): str, + vol.Required(CONF_PORT, default=port): cv.port, + vol.Required(CONF_SSL, default=ssl): bool, + vol.Optional(CONF_API_KEY, default=api_key): str, + }, + extra=vol.ALLOW_EXTRA, + ) + + +class MoonrakerHub: + """API shim to validate configuration.""" + + def __init__(self, host: str, port: int, ssl: bool, session: ClientSession) -> None: + """Initialize.""" + self.host: str = host + self.port: int = port + self.ssl: bool = ssl + self.session: ClientSession = session + self.printer_info: dict[str, Any] = {} + self.system_info: dict[str, Any] = {} + + async def authenticate(self, api_key: str) -> bool: + """Test if we can authenticate with the host.""" + client = MoonrakerClient( + host=self.host, + port=self.port, + ssl=self.ssl, + api_key=api_key, + session=self.session, + listener=None, + ) + connected = await client.connect() + self.printer_info = await client.call_method("printer.info") + self.system_info = await client.call_method("machine.system_info") + await client.disconnect() + return connected + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + clientsession = async_get_clientsession(hass) + hub = MoonrakerHub(data[CONF_HOST], data[CONF_PORT], data[CONF_SSL], clientsession) + + try: + if not await hub.authenticate(data[CONF_API_KEY]): + raise CannotConnect + except ClientNotAuthenticatedError as error: + raise InvalidAuth from error + except asyncio.TimeoutError as error: + raise CannotConnect from error + + # Return info that you want to store in the config entry. + uuid = None + try: + uuid = hub.system_info["system_info"]["cpu_info"]["serial_number"] + except KeyError: + pass + return {"title": hub.printer_info.get("hostname"), "unique_id": uuid} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for moonraker.""" + + VERSION = 1 + api_key_task = None + + def __init__(self) -> None: + """Handle a config flow for OctoPrint.""" + self.discovery_schema = None + self._reauth_entry: config_entries.ConfigEntry | None = None + + @callback + def _async_current_hosts(self): + """Return a set of hosts.""" + return { + entry.data[CONF_HOST] + for entry in self._async_current_entries() + if CONF_HOST in entry.data + } + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + data = self.discovery_schema or _schema_with_defaults() + return self.async_show_form(step_id="user", data_schema=data) + + if user_input[CONF_HOST]: + if ( + not self._reauth_entry + and user_input[CONF_HOST] in self._async_current_hosts() + ): + return self.async_abort(reason="already_configured") + + errors = {} + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_api_key" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data=user_input, + ) + return self.async_abort(reason="reauth_successful") + await self.async_set_unique_id(info.get("unique_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=_schema_with_defaults( + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_SSL], + user_input[CONF_API_KEY], + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle discovery flow.""" + local_name = discovery_info.hostname[:-1] + node_name = local_name[: -len(".local")] + address = discovery_info.properties.get("address", discovery_info.host) + + await self.async_set_unique_id(node_name) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + if local_name in self._async_current_hosts(): + return self.async_abort(reason="already_configured") + if address in self._async_current_hosts(): + return self.async_abort(reason="already_configured") + + node_type = f".{discovery_info.type}" + self.context["title_placeholders"] = { + CONF_HOST: local_name, + CONF_NAME: discovery_info.name[: -len(node_type)], + } + + self.discovery_schema = _schema_with_defaults( + host=local_name, port=discovery_info.port + ) + + return await self.async_step_user() + + async def async_step_reauth(self, _) -> FlowResult: + """Handle initial step when updating invalid credentials.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert self._reauth_entry is not None + self.context["title_placeholders"] = { + CONF_HOST: self._reauth_entry.data[CONF_HOST], + } + self.discovery_schema = _schema_with_defaults( + host=self._reauth_entry.data[CONF_HOST], + port=self._reauth_entry.data[CONF_PORT], + ssl=self._reauth_entry.data[CONF_SSL], + ) + + self.context["identifier"] = self.unique_id + return await self.async_step_user() + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/moonraker/connector.py b/homeassistant/components/moonraker/connector.py new file mode 100644 index 0000000000000..559fa20ab2b1e --- /dev/null +++ b/homeassistant/components/moonraker/connector.py @@ -0,0 +1,211 @@ +"""Moonrake API connector.""" +from __future__ import annotations + +import asyncio +import json +import logging +from random import randrange +from typing import Any + +from aiohttp import ClientConnectionError, ClientSession +from moonraker_api import MoonrakerClient, MoonrakerListener +from moonraker_api.const import ( + WEBSOCKET_STATE_CONNECTED, + WEBSOCKET_STATE_CONNECTING, + WEBSOCKET_STATE_STOPPED, +) +from moonraker_api.websockets.websocketclient import ClientNotAuthenticatedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + BACKOFF_MAX_COUNT, + BACKOFF_TIME_LOWER_LIMIT, + BACKOFF_TIME_UPPER_LIMIT, + SIGNAL_STATE_AVAILABLE, + SIGNAL_UPDATE_MODULE, + SIGNAL_UPDATE_RATE_LIMIT, +) + +_LOGGER = logging.getLogger(__name__) + + +def generate_signal(signal_name: str, entry_id: str) -> str: + """Generate a unique signal name.""" + return f"{signal_name}_{entry_id}" + + +class APIConnector(MoonrakerListener): + """Connector class to manage common interface and properties.""" + + def __init__( + self, + hass: HomeAssistant, + session: ClientSession, + config_entry: ConfigEntry, + ) -> None: + """Initialize the class.""" + self.hass = hass + self.running = False + self.retry_count = 0 + self.cache: dict[str, Any] = {} + self.entry = config_entry + self.client = MoonrakerClient( + self, + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + config_entry.data[CONF_API_KEY], + config_entry.data[CONF_SSL], + loop=hass.loop, + session=session, + timeout=30, + ) + + async def start(self) -> None: + """Start the websocket connection and set as running.""" + _LOGGER.info("Starting API connection for (%s)", self.entry.data[CONF_HOST]) + self.running = True + await self._start() + + async def _start(self, _now: Any = None) -> None: + """Start the websocket connection.""" + try: + await self.client.connect() + except ClientNotAuthenticatedError: + _LOGGER.warning( + "Authentication failed to Moonraker API for %s", + self.entry.data[CONF_HOST], + ) + self.entry.async_start_reauth(self.hass) + except ClientConnectionError: + _LOGGER.warning( + "Unable to connect to Moonraker API for %s", + self.entry.data[CONF_HOST], + ) + except asyncio.TimeoutError: + _LOGGER.warning( + "Timeout trying to connect to Moonraker API for %s", + self.entry.data[CONF_HOST], + ) + + async def stop(self) -> None: + """Stop the websocket connection.""" + self.running = False + _LOGGER.info("Stopping API connection for (%s)", self.entry.data[CONF_HOST]) + await self.client.disconnect() + + async def state_changed(self, state: str) -> None: + """Notifies of changing websocket state.""" + _LOGGER.debug("Stated changed to %s (%s)", state, self.entry.data[CONF_HOST]) + if state == WEBSOCKET_STATE_CONNECTING: + self.retry_count += 1 + elif state == WEBSOCKET_STATE_CONNECTED: + self.retry_count = 0 + if await self.client.get_klipper_status() == "ready": + await self._do_ready_handling() + elif state == WEBSOCKET_STATE_STOPPED: + async_dispatcher_send( + self.hass, + generate_signal(SIGNAL_STATE_AVAILABLE, self.entry.entry_id), + False, + ) + if self.running: + max_backoff_count = min(BACKOFF_MAX_COUNT, self.retry_count) + backoff = min( + max( + randrange(2**max_backoff_count), + BACKOFF_TIME_LOWER_LIMIT, + ), + BACKOFF_TIME_UPPER_LIMIT, + ) + _LOGGER.info( + "Unable to connect to (%s), backing off for %d seconds", + self.entry.data[CONF_HOST], + backoff, + ) + self.hass.helpers.event.async_call_later(backoff, self._start) + + async def on_exception(self, exception: BaseException) -> None: + """Notifies of exceptions from the websocket run loop.""" + _LOGGER.error( + "Moonraker API error %s (%s)", str(exception), self.entry.data[CONF_HOST] + ) + if isinstance(exception, ClientNotAuthenticatedError): + self.entry.async_start_reauth(self.hass) + + async def on_notification(self, method: str, data: Any) -> None: + """Notifies of state updates.""" + _LOGGER.debug( + "Received notification %s (%s)", method, self.entry.data[CONF_HOST] + ) + + # Subscription notifications + if method == "notify_status_update": + message = data[0] + timestamp = data[1] + for module, state in message.items(): + if module in self.cache and module in [ + "extruder", + "heater_bed", + ]: + if timestamp - self.cache[module][1] < SIGNAL_UPDATE_RATE_LIMIT: + continue + self.cache[module] = [module, timestamp] + signal = generate_signal( + SIGNAL_UPDATE_MODULE % module, self.entry.entry_id + ) + async_dispatcher_send(self.hass, signal, state) + + # Klippy status notifications + elif method == "notify_klippy_ready": + await self._do_ready_handling() + elif method in ["notify_klippy_disconnected", "notify_klippy_shutdown"]: + async_dispatcher_send( + self.hass, + generate_signal(SIGNAL_STATE_AVAILABLE, self.entry.entry_id), + False, + ) + + async def _do_ready_handling(self) -> None: + """Set status as available and request subscriptions.""" + subscriptions = { + "extruder": ["temperature", "target"], + "heater_bed": ["temperature", "target"], + "virtual_sdcard": ["progress"], + "print_stats": ["filename", "print_duration", "state"], + } + supported_modules = await self.client.get_supported_modules() + available = { + key: val for key, val in subscriptions.items() if key in supported_modules + } + _LOGGER.info( + "Fetching initial state for printer sensors (%s)", + self.entry.data[CONF_HOST], + ) + printer_state = await self.client.call_method( + "printer.objects.query", objects=available + ) + if status := printer_state.get("status"): + for module, state in status.items(): + signal = generate_signal( + SIGNAL_UPDATE_MODULE % module, self.entry.entry_id + ) + async_dispatcher_send(self.hass, signal, state) + + _LOGGER.info( + "Requesting subscriptions to printer state (%s)", self.entry.data[CONF_HOST] + ) + _LOGGER.debug( + "Subscriptions for (%s) %s", + self.entry.data[CONF_HOST], + json.dumps(available), + ) + await self.client.call_method("printer.objects.subscribe", objects=available) + async_dispatcher_send( + self.hass, + generate_signal(SIGNAL_STATE_AVAILABLE, self.entry.entry_id), + True, + ) diff --git a/homeassistant/components/moonraker/const.py b/homeassistant/components/moonraker/const.py new file mode 100644 index 0000000000000..7d1c2002b5e54 --- /dev/null +++ b/homeassistant/components/moonraker/const.py @@ -0,0 +1,22 @@ +"""Constants for the moonraker integration.""" + +BACKOFF_TIME_UPPER_LIMIT = 120 +BACKOFF_TIME_LOWER_LIMIT = 30 +BACKOFF_MAX_COUNT = 10 + +DATA_CONNECTOR = "moonraker_data_connector" + +DOMAIN = "moonraker" + +SIGNAL_UPDATE_TOOLHEAD = "moonraker_update_toolhead" +SIGNAL_UPDATE_EXTRUDER = "moonraker_update_extruder" +SIGNAL_UPDATE_HEAT_BED = "moonraker_update_heater_bed" +SIGNAL_UPDATE_FAN = "moonraker_update_fan" +SIGNAL_UPDATE_VIRTUAL_SDCARD = "moonraker_update_virtual_sdcard" +SIGNAL_UPDATE_PRINT_STATUS = "moonraker_update_print_stats" +SIGNAL_UPDATE_DISPLAY_STATUS = "moonraker_update_display_status" +SIGNAL_UPDATE_MODULE = "moonraker_update_%s" + +SIGNAL_STATE_AVAILABLE = "moonraker_state_available" + +SIGNAL_UPDATE_RATE_LIMIT = 1.0 diff --git a/homeassistant/components/moonraker/entity.py b/homeassistant/components/moonraker/entity.py new file mode 100644 index 0000000000000..9b97f054bde8d --- /dev/null +++ b/homeassistant/components/moonraker/entity.py @@ -0,0 +1,74 @@ +"""Moonrake API common entity definition.""" +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .connector import APIConnector, generate_signal +from .const import DOMAIN, SIGNAL_STATE_AVAILABLE + +_LOGGER = logging.getLogger(__name__) + + +class MoonrakerEntity(Entity): + """Generic Moonraker entity (base class).""" + + def __init__( + self, + config_entry: ConfigEntry, + connector: APIConnector, + desc: str | None, + ) -> None: + """Initialize the entity.""" + self.desc = desc + self.entry = config_entry + self.connector = connector + self.module_available = False + + @property + def name(self) -> str | None: + """Return the name of the node.""" + return f"{self.entry.title.title()} {self.desc}" + + @property + def should_poll(self) -> bool: + """Push based integrations do not poll.""" + return False + + @property + def unique_id(self) -> str: + """Return the unique id based for the node.""" + uid = self.entry.unique_id or self.entry.entry_id + return f"{uid}_{self.desc}" + + @property + def device_info(self) -> DeviceInfo | None: + """Return info about the device.""" + return DeviceInfo( + identifiers={(DOMAIN, self.entry.unique_id or self.entry.entry_id)}, + manufacturer="Moonraker", + name=self.entry.title, + ) + + @property + def available(self) -> bool: + """Return True if the entity is available, False otherwise.""" + return super().available and self.module_available + + async def async_added_to_hass(self) -> None: + """Configure entity update handlers.""" + + @callback + def update_availability(available: bool) -> None: + """Entity state update.""" + self._attr_available = available + self.async_write_ha_state() + + signal = generate_signal(SIGNAL_STATE_AVAILABLE, self.entry.entry_id) + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, update_availability) + ) diff --git a/homeassistant/components/moonraker/manifest.json b/homeassistant/components/moonraker/manifest.json new file mode 100644 index 0000000000000..e4e1ec8957706 --- /dev/null +++ b/homeassistant/components/moonraker/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "moonraker", + "name": "moonraker", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/moonraker", + "requirements": ["moonraker-api==2.0.4"], + "codeowners": ["@cmroche"], + "zeroconf": ["_moonraker._tcp.local."], + "iot_class": "local_push" +} diff --git a/homeassistant/components/moonraker/sensor.py b/homeassistant/components/moonraker/sensor.py new file mode 100644 index 0000000000000..3e724ea9f5f0f --- /dev/null +++ b/homeassistant/components/moonraker/sensor.py @@ -0,0 +1,171 @@ +"""Binary sensors for Moonraker API integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import datetime +import logging +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .connector import APIConnector, generate_signal +from .const import ( + DATA_CONNECTOR, + DOMAIN, + SIGNAL_UPDATE_EXTRUDER, + SIGNAL_UPDATE_HEAT_BED, + SIGNAL_UPDATE_PRINT_STATUS, + SIGNAL_UPDATE_VIRTUAL_SDCARD, +) +from .entity import MoonrakerEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the available Moonraker entities.""" + connector: APIConnector = hass.data[DOMAIN][config_entry.entry_id][DATA_CONNECTOR] + entities: list[SensorEntity] = [ + MoonrakerGenericSensor(config_entry, connector, x) for x in SENSOR_TYPES + ] + async_add_entities(entities) + + +@dataclass +class MoonrakerSensorKeysMixin: + """A class that describes binary sensor required keys.""" + + value: Callable[[Any], Any] | None + signal: str + + +@dataclass +class MoonrakerSensorDescription(SensorEntityDescription, MoonrakerSensorKeysMixin): + """A class that describes binary sensors.""" + + +SENSOR_TYPES = ( + MoonrakerSensorDescription( + key="extruder_temperature", + name="Extruder Temperature", + signal=SIGNAL_UPDATE_EXTRUDER, + value=lambda params: round(params["temperature"], 1), + entity_registry_enabled_default=True, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:printer-3d-nozzle", + ), + MoonrakerSensorDescription( + key="extruder_target", + name="Extruder Target Temperature", + signal=SIGNAL_UPDATE_EXTRUDER, + value=lambda params: round(params["target"], 1), + entity_registry_enabled_default=True, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:printer-3d-nozzle", + ), + MoonrakerSensorDescription( + key="bed_temperature", + name="Bed Temperature", + signal=SIGNAL_UPDATE_HEAT_BED, + value=lambda params: round(params["temperature"], 1), + entity_registry_enabled_default=True, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:radiator", + ), + MoonrakerSensorDescription( + key="bed_target", + name="Bed Target Temperature", + signal=SIGNAL_UPDATE_HEAT_BED, + value=lambda params: round(params["target"], 1), + entity_registry_enabled_default=True, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:radiator", + ), + MoonrakerSensorDescription( + key="print_progress", + name="Print Progress", + signal=SIGNAL_UPDATE_VIRTUAL_SDCARD, + value=lambda params: round(params["progress"] * 100), + entity_registry_enabled_default=True, + native_unit_of_measurement=PERCENTAGE, + ), + MoonrakerSensorDescription( + key="print_duration", + name="Print Duration", + signal=SIGNAL_UPDATE_PRINT_STATUS, + value=lambda params: str( + datetime.timedelta(seconds=round(params["print_duration"])) + ), + entity_registry_enabled_default=True, + icon="mdi:clock-outline", + ), + MoonrakerSensorDescription( + key="print_file", + name="Print File", + signal=SIGNAL_UPDATE_PRINT_STATUS, + value=lambda params: params["filename"], + entity_registry_enabled_default=True, + icon="mdi:file", + ), +) + + +class MoonrakerGenericSensor(MoonrakerEntity, SensorEntity): + """Binary sensor representing printing state.""" + + entity_description: MoonrakerSensorDescription + + def __init__( + self, + entry: ConfigEntry, + connector: APIConnector, + description: MoonrakerSensorDescription, + ) -> None: + """Initialize a new printing binary sensor.""" + super().__init__(entry, connector, description.name) + self.entity_description = description + self._attr_entity_registry_enabled_default = ( + description.entity_registry_enabled_default + ) + + async def async_added_to_hass(self) -> None: + """Configure entity update handlers.""" + await super().async_added_to_hass() + + @callback + def update_state(params: Any) -> None: + """Entity state update.""" + try: + if self.entity_description.value: + self._attr_native_value = self.entity_description.value(params) + self.module_available = True + except KeyError: + pass + else: + self.async_write_ha_state() + + signal = generate_signal(self.entity_description.signal, self.entry.entry_id) + self.async_on_remove(async_dispatcher_connect(self.hass, signal, update_state)) diff --git a/homeassistant/components/moonraker/strings.json b/homeassistant/components/moonraker/strings.json new file mode 100644 index 0000000000000..a1a5c9238f541 --- /dev/null +++ b/homeassistant/components/moonraker/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "flow_title": "Moonraker Printer: {host}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/moonraker/translations/en.json b/homeassistant/components/moonraker/translations/en.json new file mode 100644 index 0000000000000..439b97abc0c1a --- /dev/null +++ b/homeassistant/components/moonraker/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "auth_failed": "Failed to retrieve application api key", + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "Moonraker Printer: {host}", + "step": { + "user": { + "data": { + "api_key": "API Key", + "host": "Host", + "port": "Port", + "ssl": "Uses an SSL certificate" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8fc33a593c421..27812a7c89176 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -213,6 +213,7 @@ "moehlenhoff_alpha2", "monoprice", "moon", + "moonraker", "motion_blinds", "motioneye", "mqtt", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 98be7817113c0..a24701a44671c 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -249,6 +249,11 @@ "name": "yeelink-*" } ], + "_moonraker._tcp.local.": [ + { + "domain": "moonraker" + } + ], "_nanoleafapi._tcp.local.": [ { "domain": "nanoleaf" diff --git a/requirements_all.txt b/requirements_all.txt index dbc0ea1ad47cb..983b6f4a455fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1019,6 +1019,9 @@ mitemp_bt==0.0.5 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.1.2 +# homeassistant.components.moonraker +moonraker-api==2.0.4 + # homeassistant.components.motion_blinds motionblinds==0.6.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d1d44cdfa445e..ed8cb1eedb369 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,6 +690,9 @@ minio==5.0.10 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.1.2 +# homeassistant.components.moonraker +moonraker-api==2.0.4 + # homeassistant.components.motion_blinds motionblinds==0.6.4 diff --git a/tests/components/moonraker/__init__.py b/tests/components/moonraker/__init__.py new file mode 100644 index 0000000000000..a08c535afdef9 --- /dev/null +++ b/tests/components/moonraker/__init__.py @@ -0,0 +1 @@ +"""Tests for the moonraker integration.""" diff --git a/tests/components/moonraker/conftest.py b/tests/components/moonraker/conftest.py new file mode 100644 index 0000000000000..8b0f85e8c6344 --- /dev/null +++ b/tests/components/moonraker/conftest.py @@ -0,0 +1,28 @@ +"""esphome session fixtures.""" + +from typing import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_connector() -> Generator: + """Mock API connector.""" + with patch("homeassistant.components.moonraker.APIConnector") as mock_client: + + def mock_constructor(host, port, ssl, session=None): + """Fake the client constructor.""" + mock_client.host = host + mock_client.port = port + mock_client.ssl = ssl + mock_client.session = session + return mock_client + + mock_client.side_effect = mock_constructor + mock_client.start = AsyncMock(return_value=True) + mock_client.stop = AsyncMock() + mock_client.client = MagicMock() + mock_client.client.call_method = AsyncMock() + + yield mock_client diff --git a/tests/components/moonraker/const.py b/tests/components/moonraker/const.py new file mode 100644 index 0000000000000..cb202db8089d3 --- /dev/null +++ b/tests/components/moonraker/const.py @@ -0,0 +1,3 @@ +"""Constants for Moonraker integration.""" + +DOMAIN = "moonraker" diff --git a/tests/components/moonraker/test_config_flow.py b/tests/components/moonraker/test_config_flow.py new file mode 100644 index 0000000000000..9963dc70ecda7 --- /dev/null +++ b/tests/components/moonraker/test_config_flow.py @@ -0,0 +1,394 @@ +"""Test the moonraker config flow.""" +import asyncio +from typing import Any, Generator +from unittest.mock import AsyncMock, Mock, patch + +from moonraker_api.websockets.websocketclient import ClientNotAuthenticatedError +import pytest + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.moonraker.config_flow import CannotConnect +from homeassistant.components.moonraker.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.moonraker.async_setup_entry", return_value=True + ): + yield + + +def fake_api_call_method(method: str, **kwargs: Any) -> Any: + """Return data for generic API calls.""" + if method == "printer.info": + return {"hostname": "test-host"} + elif method == "machine.system_info": + return {"system_info": {"cpu_info": {"serial_number": "abcd1234"}}} + return {"res_data": "success"} + + +@pytest.fixture +def moonraker_client() -> Generator: + """Mock Moonraker API client.""" + with patch( + "homeassistant.components.moonraker.config_flow.MoonrakerClient" + ) as client_mock: + + def mock_constructor( + listener, host, port, api_key, ssl, loop=None, timeout=0, session=None + ): + """Fake the client constructor.""" + client_mock.listener = listener + client_mock.host = host + client_mock.port = port + client_mock.api_key = api_key + client_mock.ssl = ssl + client_mock.loop = loop + client_mock.session = session + client_mock.timeout = timeout + return client_mock + + client_mock.side_effect = mock_constructor + client_mock.connect = AsyncMock(return_value=True) + client_mock.disconnect = AsyncMock(return_value=True) + client_mock.call_method = AsyncMock(side_effect=fake_api_call_method) + yield client_mock + + +def get_mock_service_info() -> zeroconf.ZeroconfServiceInfo: + """Get a mock service info object.""" + return zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + addresses=["192.168.43.183"], + port=7120, + hostname="test-host.local.", + type="_moonraker._tcp.local.", + name="Moonraker Instance", + properties={}, + ) + + +async def test_user_connection_works( + hass: HomeAssistant, moonraker_client: Mock, mock_zeroconf: Mock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: None, + }, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_API_KEY: None, + CONF_SSL: False, + } + assert result["title"] == "test-host" + + +@pytest.mark.parametrize("exception", [CannotConnect, asyncio.TimeoutError]) +async def test_user_resolve_connection_error( + hass: HomeAssistant, + moonraker_client: Mock, + mock_zeroconf: Mock, + exception: BaseException, +) -> None: + """Test we handle invalid auth.""" + moonraker_client.connect.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: None, + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_resolve_connection_fails_error( + hass: HomeAssistant, moonraker_client: Mock, mock_zeroconf: Mock +) -> None: + """Test we handle invalid auth.""" + moonraker_client.connect.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: None, + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_resolve_authentication_error( + hass: HomeAssistant, moonraker_client: Mock, mock_zeroconf: Mock +) -> None: + """Test we handle invalid auth.""" + moonraker_client.connect.side_effect = ClientNotAuthenticatedError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: None, + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_api_key"} + + +async def test_user_resolve_unknown_error( + hass: HomeAssistant, moonraker_client: Mock, mock_zeroconf: Mock +) -> None: + """Test we handle invalid auth.""" + moonraker_client.connect.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: None, + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + +async def test_user_older_clients_succeed( + hass: HomeAssistant, moonraker_client: Mock, mock_zeroconf: Mock +) -> None: + """Test we handle older clients without a serial number.""" + + def _fake_api_call_method(method: str, **kwargs: Any) -> Any: + """Override the fake call_method responses.""" + if method == "machine.system_info": + return {"system_info": {"cpu_info": {}}} + else: + return fake_api_call_method(method, **kwargs) + + moonraker_client.call_method.side_effect = _fake_api_call_method + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: None, + }, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_API_KEY: None, + CONF_SSL: False, + } + assert result["title"] == "test-host" + + +async def test_discovery_initiation( + hass: HomeAssistant, moonraker_client: Mock, mock_zeroconf: Mock +) -> None: + """Test discovery importing works.""" + service_info = get_mock_service_info() + flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + ) + + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], user_input={} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-host" + assert result["data"][CONF_HOST] == "test-host.local" + assert result["data"][CONF_PORT] == 7120 + + assert result["result"] + + +async def test_user_already_configured_hostname( + hass: HomeAssistant, moonraker_client: Mock +) -> None: + """Test discovery aborts if already configured via hostname.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test-host.local", + CONF_PORT: 7120, + CONF_SSL: False, + CONF_API_KEY: "", + }, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: "test-host.local", + CONF_PORT: 7120, + CONF_SSL: False, + CONF_API_KEY: "", + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_discovery_already_configured_hostname( + hass: HomeAssistant, moonraker_client: Mock +) -> None: + """Test discovery aborts if already configured via hostname.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test-host.local", + CONF_PORT: 7120, + CONF_SSL: False, + CONF_API_KEY: "", + }, + ) + + entry.add_to_hass(hass) + + service_info = get_mock_service_info() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_discovery_already_configured_ip( + hass: HomeAssistant, moonraker_client: Mock +) -> None: + """Test discovery aborts if already configured via static IP.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.43.183", + CONF_PORT: 7120, + CONF_SSL: False, + CONF_API_KEY: "", + }, + ) + + entry.add_to_hass(hass) + + service_info = get_mock_service_info() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_discovery_duplicate_data( + hass: HomeAssistant, moonraker_client: Mock +) -> None: + """Test discovery aborts if same mDNS packet arrives.""" + service_info = get_mock_service_info() + result = await hass.config_entries.flow.async_init( + DOMAIN, data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_in_progress" + + +async def test_reauth_initiation( + hass: HomeAssistant, moonraker_client: Mock, mock_zeroconf: Mock +) -> None: + """Test reauth initiation shows form.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 7120, + CONF_SSL: False, + CONF_API_KEY: "", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 7120, + CONF_SSL: False, + CONF_API_KEY: "", + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/moonraker/test_connector.py b/tests/components/moonraker/test_connector.py new file mode 100644 index 0000000000000..f7d2ed986a8e0 --- /dev/null +++ b/tests/components/moonraker/test_connector.py @@ -0,0 +1,354 @@ +"""Test the moonrker API connector.""" +from __future__ import annotations + +import asyncio +from typing import Any, Generator +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientConnectionError +from moonraker_api import ClientNotAuthenticatedError +from moonraker_api.const import ( + WEBSOCKET_STATE_CONNECTED, + WEBSOCKET_STATE_CONNECTING, + WEBSOCKET_STATE_STOPPED, +) +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.moonraker.connector import APIConnector, generate_signal +from homeassistant.components.moonraker.const import ( + DATA_CONNECTOR, + SIGNAL_STATE_AVAILABLE, + SIGNAL_UPDATE_MODULE, +) +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DOMAIN + +from tests.common import MockConfigEntry + +HOST_NAME_1 = "test_host_1" +HOST_NAME_2 = "test_host_2" + + +def fake_api_call_method(method: str, **kwargs: Any) -> Any: + """Return data for generic API calls.""" + if method == "printer.objects.query": + return { + "status": { + "extruder": { + "temperature": 0.0, + "target": 0.0, + "power": 0.0, + } + } + } + return {"res_data": "success"} + + +@pytest.fixture +def moonraker_client() -> Generator: + """Mock Moonraker API client.""" + with patch( + "homeassistant.components.moonraker.connector.MoonrakerClient" + ) as client_mock: + + def mock_constructor( + listener, host, port, api_key, ssl, loop=None, timeout=0, session=None + ): + """Fake the client constructor.""" + client_mock.listener = listener + client_mock.host = host + client_mock.port = port + client_mock.api_key = api_key + client_mock.ssl = ssl + client_mock.loop = loop + client_mock.session = session + client_mock.timeout = timeout + return client_mock + + client_mock.side_effect = mock_constructor + client_mock.connect = AsyncMock(return_value=True) + client_mock.disconnect = AsyncMock(return_value=True) + client_mock.get_klipper_status = AsyncMock(return_value="disconnected") + client_mock.get_supported_modules = AsyncMock( + return_value=["extruder", "heater_bed", "virtual_sdcard", "print_stats"] + ) + client_mock.call_method = AsyncMock(side_effect=fake_api_call_method) + yield client_mock + + +@pytest.fixture +def moonraker_listener() -> Generator: + """Mock Moonraker API event listener.""" + with patch( + "homeassistant.components.moonraker.connector.MoonrakerListener", autospec=True + ) as listener_mock: + yield listener_mock + + +def get_mock_entry(hass: HomeAssistant, entry_name: str) -> MockConfigEntry: + """Generate a mock config entry for testing.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=entry_name, + title=entry_name, + data={ + CONF_HOST: f"{entry_name}.local", + CONF_PORT: 7125, + CONF_SSL: False, + CONF_API_KEY: "", + }, + ) + entry.add_to_hass(hass) + return entry + + +async def setup_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Complete setup for entry.""" + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def setup_and_connect( + hass: HomeAssistant, moonraker_client: Mock +) -> APIConnector: + """Create a mock entry, setup and connect to API.""" + entry = get_mock_entry(hass, HOST_NAME_1) + await setup_entry(hass, entry) + moonraker_client.start() + + return hass.data[DOMAIN][entry.entry_id][DATA_CONNECTOR] + + +async def test_generated_signals_are_unique( + hass: HomeAssistant, mock_connector: Mock +) -> None: + """Test that generated updates signas are unique per entry.""" + entry_1 = get_mock_entry(hass, HOST_NAME_1) + entry_2 = get_mock_entry(hass, HOST_NAME_2) + + signal_1 = generate_signal(SIGNAL_STATE_AVAILABLE, entry_1.entry_id) + signal_2 = generate_signal(SIGNAL_STATE_AVAILABLE, entry_2.entry_id) + + assert signal_1 != signal_2 + + +async def test_start_stop_service_success( + hass: HomeAssistant, moonraker_client: Mock +) -> None: + """Test starting the moonraker api client.""" + entry = get_mock_entry(hass, HOST_NAME_1) + await setup_entry(hass, entry) + + assert hass.data[DOMAIN][entry.entry_id] + assert moonraker_client.call_count == 1 + assert moonraker_client.connect.call_count == 1 + + connector = hass.data[DOMAIN][entry.entry_id][DATA_CONNECTOR] + assert connector is not None + assert connector.running is True + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert moonraker_client.disconnect.call_count == 1 + + +async def test_start_service_authentication_error( + hass: HomeAssistant, moonraker_client: Mock +) -> None: + """Test starting the moonraker API client with an auth error.""" + moonraker_client.connect.side_effect = ClientNotAuthenticatedError + + with patch( + "homeassistant.components.moonraker.config_flow.ConfigFlow.async_step_reauth", + return_value={"type": data_entry_flow.RESULT_TYPE_FORM}, + ) as mock_reauth: + entry = get_mock_entry(hass, HOST_NAME_1) + try: + await setup_entry(hass, entry) + except ClientNotAuthenticatedError: + pytest.fail("ClientNotAuthenticatedError should not be unhandled.") + + assert mock_reauth.call_count == 1 + + +async def test_start_service_connection_error( + hass: HomeAssistant, aiohttp_server: Mock, moonraker_client: Mock +) -> None: + """Test starting the moonraker client with a connection error.""" + moonraker_client.connect.side_effect = ClientConnectionError + entry = get_mock_entry(hass, HOST_NAME_1) + try: + await setup_entry(hass, entry) + except ClientConnectionError: + pytest.fail("ClientConnectionError should not be unhandled.") + + +async def test_start_service_timeout_error( + hass: HomeAssistant, aiohttp_server: Mock, moonraker_client: Mock +) -> None: + """Test starting the moonraker client with a connection error.""" + moonraker_client.connect.side_effect = asyncio.TimeoutError + entry = get_mock_entry(hass, HOST_NAME_1) + try: + await setup_entry(hass, entry) + except asyncio.TimeoutError: + pytest.fail("asyncio.TimeoutError should not be unhandled.") + + +async def test_websocket_connecting_handler( + hass: HomeAssistant, moonraker_client: Mock +) -> None: + """Test handling of connecting event.""" + connector = await setup_and_connect(hass, moonraker_client) + assert connector.retry_count == 0 + await connector.state_changed(WEBSOCKET_STATE_CONNECTING) + assert connector.retry_count == 1 + await connector.state_changed(WEBSOCKET_STATE_CONNECTED) + assert connector.retry_count == 0 + assert moonraker_client.get_klipper_status.call_count == 1 + + +async def test_websocket_connected_ready_handler( + hass: HomeAssistant, moonraker_client: Mock +) -> None: + """Test API instance ready on connection.""" + moonraker_client.get_klipper_status.return_value = "ready" + + entry = get_mock_entry(hass, HOST_NAME_1) + await setup_entry(hass, entry) + moonraker_client.start() + connector = hass.data[DOMAIN][entry.entry_id][DATA_CONNECTOR] + + update_signal = MagicMock() + update_signal_name = generate_signal( + SIGNAL_UPDATE_MODULE % "extruder", entry.entry_id + ) + async_dispatcher_connect(hass, update_signal_name, update_signal) + status_signal = MagicMock() + status_signal_name = generate_signal(SIGNAL_STATE_AVAILABLE, entry.entry_id) + async_dispatcher_connect(hass, status_signal_name, status_signal) + await connector.state_changed(WEBSOCKET_STATE_CONNECTED) + await hass.async_block_till_done() + + assert moonraker_client.get_klipper_status.call_count == 1 + assert moonraker_client.get_supported_modules.call_count == 1 + assert update_signal.call_args.args == ( + {"temperature": 0.0, "target": 0.0, "power": 0.0}, + ) + assert status_signal.call_args.args == (True,) + + +@patch("homeassistant.components.moonraker.connector.BACKOFF_TIME_LOWER_LIMIT", 0) +async def test_websocket_disconnected_handler( + hass: HomeAssistant, moonraker_client: Mock +) -> None: + """Test API instance disconnected handler.""" + entry = get_mock_entry(hass, HOST_NAME_1) + await setup_entry(hass, entry) + moonraker_client.start() + connector = hass.data[DOMAIN][entry.entry_id][DATA_CONNECTOR] + + status_signal = MagicMock() + status_signal_name = generate_signal(SIGNAL_STATE_AVAILABLE, entry.entry_id) + async_dispatcher_connect(hass, status_signal_name, status_signal) + await connector.state_changed(WEBSOCKET_STATE_STOPPED) + await hass.async_block_till_done() + + assert status_signal.call_args.args == (False,) + assert moonraker_client.connect.call_count == 2 + + +async def test_exception_not_authorized_handler( + hass: HomeAssistant, moonraker_client: Mock +) -> None: + """Test a ClientNotAuthenticatedError raised by the API.""" + connector = await setup_and_connect(hass, moonraker_client) + with patch( + "homeassistant.components.moonraker.config_flow.ConfigFlow.async_step_reauth", + return_value={"type": data_entry_flow.RESULT_TYPE_FORM}, + ) as mock_reauth: + try: + await connector.on_exception(ClientNotAuthenticatedError()) + except ClientNotAuthenticatedError: + pytest.fail("ClientNotAuthenticated error should not be unhandled.") + await hass.async_block_till_done() + assert mock_reauth.call_count == 1 + + +async def test_update_notification_handler( + hass: HomeAssistant, moonraker_client: Mock +) -> None: + """Test handling of update notifications from the API.""" + entry = get_mock_entry(hass, HOST_NAME_1) + await setup_entry(hass, entry) + moonraker_client.start() + + update_signal = MagicMock() + update_signal_name = generate_signal( + SIGNAL_UPDATE_MODULE % "extruder", entry.entry_id + ) + async_dispatcher_connect(hass, update_signal_name, update_signal) + + update_data = ( + { + "extruder": { + "temperature": 0.0, + "target": 0.0, + "power": 0.0, + } + }, + 0, + ) + + # First call to update with this timestamp will trigger an update + connector = hass.data[DOMAIN][entry.entry_id][DATA_CONNECTOR] + await connector.on_notification("notify_status_update", update_data) + await hass.async_block_till_done() + + assert update_signal.call_args.args == (update_data[0]["extruder"],) + + # Second call with same timestamp will not dispatch + await connector.on_notification("notify_status_update", update_data) + await hass.async_block_till_done() + + assert update_signal.call_count == 1 + + # Third call 1 second later will dispatch again + update_data = (update_data[0], 1) + await connector.on_notification("notify_status_update", update_data) + await hass.async_block_till_done() + + assert update_signal.call_count == 2 + + +@pytest.mark.parametrize( + "notification,status", + [ + ("notify_klippy_ready", True), + ("notify_klippy_disconnected", False), + ("notify_klippy_shutdown", False), + ], +) +async def test_notify_klipper_ready_handler( + hass: HomeAssistant, moonraker_client: Mock, notification: str, status: bool +) -> None: + """Test handling of klipper ready message.""" + moonraker_client.get_klipper_status.return_value = "ready" + + entry = get_mock_entry(hass, HOST_NAME_1) + await setup_entry(hass, entry) + moonraker_client.start() + connector = hass.data[DOMAIN][entry.entry_id][DATA_CONNECTOR] + + status_signal = MagicMock() + status_signal_name = generate_signal(SIGNAL_STATE_AVAILABLE, entry.entry_id) + async_dispatcher_connect(hass, status_signal_name, status_signal) + await connector.on_notification(notification, None) + await hass.async_block_till_done() + + assert status_signal.call_count == 1 + assert status_signal.call_args.args == (status,) diff --git a/tests/components/moonraker/test_init.py b/tests/components/moonraker/test_init.py new file mode 100644 index 0000000000000..471231e1dddef --- /dev/null +++ b/tests/components/moonraker/test_init.py @@ -0,0 +1,62 @@ +"""Tests for the Gree Integration.""" +from unittest.mock import Mock, patch + +from homeassistant.components.moonraker.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +ENTRY_NAME = "test_host_1" + + +def get_mock_entry(hass: HomeAssistant, entry_name: str) -> MockConfigEntry: + """Generate a mock config entry for testing.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=entry_name, + title=entry_name, + data={ + CONF_HOST: f"{entry_name}.local", + CONF_PORT: 7125, + CONF_SSL: False, + CONF_API_KEY: "", + }, + ) + entry.add_to_hass(hass) + return entry + + +async def setup_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Complete setup for config entry.""" + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def test_setup_simple(hass: HomeAssistant) -> None: + """Test gree integration is setup.""" + with patch( + "homeassistant.components.moonraker.sensor.async_setup_entry", + return_value=True, + ) as sensor_setup: + entry = get_mock_entry(hass, ENTRY_NAME) + await setup_entry(hass, entry) + + assert len(sensor_setup.mock_calls) == 1 + assert entry.state is ConfigEntryState.LOADED + + # No flows started + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_unload_config_entry(hass: HomeAssistant, mock_connector: Mock) -> None: + """Test that the async_unload_entry works.""" + # As we have currently no configuration, we just to pass the domain here. + entry = get_mock_entry(hass, ENTRY_NAME) + await setup_entry(hass, entry) + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/moonraker/test_sensor.py b/tests/components/moonraker/test_sensor.py new file mode 100644 index 0000000000000..6889fe57771a4 --- /dev/null +++ b/tests/components/moonraker/test_sensor.py @@ -0,0 +1,105 @@ +"""Test the binary sensors.""" +from typing import Any +from unittest.mock import Mock + +import pytest + +from homeassistant.components.moonraker.connector import generate_signal +from homeassistant.components.moonraker.const import ( + SIGNAL_UPDATE_EXTRUDER, + SIGNAL_UPDATE_HEAT_BED, + SIGNAL_UPDATE_PRINT_STATUS, + SIGNAL_UPDATE_VIRTUAL_SDCARD, +) +from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import DOMAIN + +from tests.common import MockConfigEntry + +HOST_NAME = "test_host" + + +async def setup_component( + hass: HomeAssistant, entity_name: str +) -> tuple[str, ConfigEntry]: + """Set up sensor component.""" + entity_id = f"{SENSOR}.{entity_name}" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=entity_name, + title=entity_name, + data={ + CONF_HOST: f"{HOST_NAME}.local", + CONF_PORT: 7125, + CONF_SSL: False, + CONF_API_KEY: "", + }, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return entity_id, config_entry + + +@pytest.mark.parametrize( + "name,signal,key,value,result", + [ + ("extruder_temperature", SIGNAL_UPDATE_EXTRUDER, "temperature", 26.97, "27.0"), + ( + "extruder_target_temperature", + SIGNAL_UPDATE_EXTRUDER, + "target", + 210.00, + "210.0", + ), + ("bed_temperature", SIGNAL_UPDATE_HEAT_BED, "temperature", 26.97, "27.0"), + ("bed_target_temperature", SIGNAL_UPDATE_HEAT_BED, "target", 210.00, "210.0"), + ("print_progress", SIGNAL_UPDATE_VIRTUAL_SDCARD, "progress", 0.0123, "1"), + ( + "print_duration", + SIGNAL_UPDATE_PRINT_STATUS, + "print_duration", + (60 * 60) + 60 + 1, # 1h, 1m, 1s + "1:01:01", + ), + ( + "print_file", + SIGNAL_UPDATE_PRINT_STATUS, + "filename", + "testfile.gcode", + "testfile.gcode", + ), + ], +) +async def test_extruder_temperature( + hass: HomeAssistant, + mock_connector: Mock, + name: str, + signal: str, + key: str, + value: Any, + result: Any, +) -> None: + """Test the extruder temperature sensor.""" + entity_id, entry = await setup_component(hass, f"{HOST_NAME}") + state = hass.states.get(f"{entity_id}_{name}") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + entry_signal = generate_signal(signal, entry.entry_id) + async_dispatcher_send(hass, entry_signal, {key: value}) + await hass.async_block_till_done() + state = hass.states.get(f"{entity_id}_{name}") + assert state.state == result