diff --git a/CODEOWNERS b/CODEOWNERS index 44b7e4bce36..cd7ae315b09 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -289,6 +289,8 @@ build.json @home-assistant/supervisor /tests/components/doorbird/ @oblogic7 @bdraco @flacjacket /homeassistant/components/dormakaba_dkey/ @emontnemery /tests/components/dormakaba_dkey/ @emontnemery +/homeassistant/components/dremel_3d_printer/ @tkdrob +/tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/dsmr/ @Robbie1221 @frenck /tests/components/dsmr/ @Robbie1221 @frenck /homeassistant/components/dsmr_reader/ @depl0y @glodenox diff --git a/homeassistant/components/dremel_3d_printer/__init__.py b/homeassistant/components/dremel_3d_printer/__init__.py new file mode 100644 index 00000000000..4daafea5db8 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/__init__.py @@ -0,0 +1,41 @@ +"""The Dremel 3D Printer (3D20, 3D40, 3D45) integration.""" +from __future__ import annotations + +from dremel3dpy import Dremel3DPrinter +from requests.exceptions import ConnectTimeout, HTTPError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import Dremel3DPrinterDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up Dremel 3D Printer from a config entry.""" + try: + api = await hass.async_add_executor_job( + Dremel3DPrinter, config_entry.data[CONF_HOST] + ) + + except (ConnectTimeout, HTTPError) as ex: + raise ConfigEntryNotReady( + f"Unable to connect to Dremel 3D Printer: {ex}" + ) from ex + + coordinator = Dremel3DPrinterDataUpdateCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Dremel config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data.pop(DOMAIN) + return unload_ok diff --git a/homeassistant/components/dremel_3d_printer/config_flow.py b/homeassistant/components/dremel_3d_printer/config_flow.py new file mode 100644 index 00000000000..6fa4d2e0a5b --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Dremel 3D Printer (3D20, 3D40, 3D45).""" +from __future__ import annotations + +from json.decoder import JSONDecodeError +from typing import Any + +from dremel3dpy import Dremel3DPrinter +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, LOGGER + + +def _schema_with_defaults(host: str = "") -> vol.Schema: + return vol.Schema({vol.Required(CONF_HOST, default=host): cv.string}) + + +class Dremel3DPrinterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Dremel 3D Printer.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=_schema_with_defaults(), + ) + host = user_input[CONF_HOST] + + try: + api = await self.hass.async_add_executor_job(Dremel3DPrinter, host) + except (ConnectTimeout, HTTPError, JSONDecodeError): + errors = {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + LOGGER.exception("An unknown error has occurred") + errors = {"base": "unknown"} + + if errors: + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=_schema_with_defaults(host=host), + ) + + await self.async_set_unique_id(api.get_serial_number()) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=api.get_title(), data={CONF_HOST: host}) diff --git a/homeassistant/components/dremel_3d_printer/const.py b/homeassistant/components/dremel_3d_printer/const.py new file mode 100644 index 00000000000..611b3b86306 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/const.py @@ -0,0 +1,11 @@ +"""Constants for the Dremel 3D Printer (3D20, 3D40, 3D45) integration.""" +from __future__ import annotations + +import logging + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "dremel_3d_printer" + +ATTR_EXTRUDER = "extruder" +ATTR_PLATFORM = "platform" diff --git a/homeassistant/components/dremel_3d_printer/coordinator.py b/homeassistant/components/dremel_3d_printer/coordinator.py new file mode 100644 index 00000000000..81e0053fd77 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/coordinator.py @@ -0,0 +1,36 @@ +"""Data update coordinator for the Dremel 3D Printer integration.""" + +from datetime import timedelta + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class Dremel3DPrinterDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching Dremel 3D Printer data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, api: Dremel3DPrinter) -> None: + """Initialize Dremel 3D Printer data update coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=10), + ) + self.api = api + + async def _async_update_data(self) -> None: + """Update data via APIs.""" + try: + await self.hass.async_add_executor_job(self.api.refresh) + except RuntimeError as ex: + raise UpdateFailed( + f"Unable to refresh printer information: Printer offline: {ex}" + ) from ex diff --git a/homeassistant/components/dremel_3d_printer/entity.py b/homeassistant/components/dremel_3d_printer/entity.py new file mode 100644 index 00000000000..392869a138b --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/entity.py @@ -0,0 +1,41 @@ +"""Entity representing a Dremel 3D Printer.""" + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import Dremel3DPrinterDataUpdateCoordinator + + +class Dremel3DPrinterEntity(CoordinatorEntity[Dremel3DPrinterDataUpdateCoordinator]): + """Defines a Dremel 3D Printer device entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: Dremel3DPrinterDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the base device entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Dremel printer.""" + return DeviceInfo( + identifiers={(DOMAIN, self._api.get_serial_number())}, + manufacturer=self._api.get_manufacturer(), + model=self._api.get_model(), + name=self._api.get_title(), + sw_version=self._api.get_firmware_version(), + ) + + @property + def _api(self) -> Dremel3DPrinter: + """Return to api from coordinator.""" + return self.coordinator.api diff --git a/homeassistant/components/dremel_3d_printer/manifest.json b/homeassistant/components/dremel_3d_printer/manifest.json new file mode 100644 index 00000000000..12d4e4003c4 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "dremel_3d_printer", + "name": "Dremel 3D Printer", + "codeowners": ["@tkdrob"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/dremel_3d_printer", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["dremel3dpy==2.1.1"] +} diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py new file mode 100644 index 00000000000..00002e44c4e --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -0,0 +1,284 @@ +"""Support for monitoring Dremel 3D Printer sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfInformation, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow +from homeassistant.util.variance import ignore_variance + +from .const import ATTR_EXTRUDER, ATTR_PLATFORM, DOMAIN +from .entity import Dremel3DPrinterEntity + + +@dataclass +class Dremel3DPrinterSensorEntityMixin: + """Mixin for Dremel 3D Printer sensor.""" + + value_fn: Callable[[Dremel3DPrinter, str], StateType | datetime] + + +@dataclass +class Dremel3DPrinterSensorEntityDescription( + SensorEntityDescription, Dremel3DPrinterSensorEntityMixin +): + """Describes a Dremel 3D Printer sensor.""" + + available_fn: Callable[[Dremel3DPrinter, str], bool] = lambda api, _: True + + +SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( + Dremel3DPrinterSensorEntityDescription( + key="job_phase", + name="Job phase", + icon="mdi:printer-3d", + value_fn=lambda api, _: api.get_printing_status(), + ), + Dremel3DPrinterSensorEntityDescription( + key="remaining_time", + name="Remaining time", + device_class=SensorDeviceClass.TIMESTAMP, + available_fn=lambda api, key: api.get_job_status()[key] > 0, + value_fn=ignore_variance( + lambda api, key: utcnow() - timedelta(seconds=api.get_job_status()[key]), + timedelta(minutes=2), + ), + ), + Dremel3DPrinterSensorEntityDescription( + key="progress", + name="Progress", + icon="mdi:printer-3d-nozzle", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_printing_progress(), + ), + Dremel3DPrinterSensorEntityDescription( + key="chamber", + name="Chamber", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_temperature_type(key), + ), + Dremel3DPrinterSensorEntityDescription( + key="platform_temperature", + name="Platform temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_type(ATTR_PLATFORM), + ), + Dremel3DPrinterSensorEntityDescription( + key="target_platform_temperature", + name="Target platform temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_PLATFORM)[ + "target_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key="max_platform_temperature", + name="Max platform temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_PLATFORM)[ + "max_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key=ATTR_EXTRUDER, + name="Extruder", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_temperature_type(key), + ), + Dremel3DPrinterSensorEntityDescription( + key="target_extruder_temperature", + name="Target extruder temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_EXTRUDER)[ + "target_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key="max_extruder_temperature", + name="Max extruder temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_EXTRUDER)[ + "max_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key="network_build", + name="Network build", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_job_status()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="filament", + name="Filament", + icon="mdi:printer-3d-nozzle", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_job_status()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="elapsed_time", + name="Elapsed time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + available_fn=lambda api, _: api.get_printing_status() == "building", + value_fn=ignore_variance( + lambda api, key: utcnow() - timedelta(seconds=api.get_job_status()[key]), + timedelta(minutes=2), + ), + ), + Dremel3DPrinterSensorEntityDescription( + key="estimated_total_time", + name="Estimated total time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + available_fn=lambda api, key: api.get_job_status()[key] > 0, + value_fn=ignore_variance( + lambda api, key: utcnow() - timedelta(seconds=api.get_job_status()[key]), + timedelta(minutes=2), + ), + ), + Dremel3DPrinterSensorEntityDescription( + key="job_status", + name="Job status", + icon="mdi:printer-3d", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_job_status()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="job_name", + name="Job name", + icon="mdi:file", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_job_name(), + ), + Dremel3DPrinterSensorEntityDescription( + key="api_version", + name="API version", + icon="mdi:api", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="host", + name="Host", + icon="mdi:ip-network", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="connection_type", + name="Connection type", + icon="mdi:network", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="available_storage", + name="Available storage", + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key] * 100, + ), + Dremel3DPrinterSensorEntityDescription( + key="hours_used", + name="Hours used", + icon="mdi:clock", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the available Dremel 3D Printer sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + Dremel3DPrinterSensor(coordinator, description) for description in SENSOR_TYPES + ) + + +class Dremel3DPrinterSensor(Dremel3DPrinterEntity, SensorEntity): + """Representation of an Dremel 3D Printer sensor.""" + + entity_description: Dremel3DPrinterSensorEntityDescription + + @property + def available(self) -> bool: + """Return True if the entity is available.""" + return super().available and self.entity_description.available_fn( + self._api, self.entity_description.key + ) + + @property + def native_value(self) -> StateType | datetime: + """Return the sensor state.""" + return self.entity_description.value_fn(self._api, self.entity_description.key) diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json new file mode 100644 index 00000000000..64b95cbfd05 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ca81e7befaf..efb821a3b2b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -101,6 +101,7 @@ FLOWS = { "dnsip", "doorbird", "dormakaba_dkey", + "dremel_3d_printer", "dsmr", "dsmr_reader", "dunehd", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6418e93aa03..37f7c2e6071 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1172,6 +1172,12 @@ "integration_type": "hub", "config_flow": false }, + "dremel_3d_printer": { + "name": "Dremel 3D Printer", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "dsmr": { "name": "DSMR Slimme Meter", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index dd6e25400a3..f8b232073f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -616,6 +616,9 @@ doorbirdpy==2.1.0 # homeassistant.components.dovado dovado==0.4.1 +# homeassistant.components.dremel_3d_printer +dremel3dpy==2.1.1 + # homeassistant.components.dsmr dsmr_parser==0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fe6051f676..c21d02028de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,6 +493,9 @@ discovery30303==0.2.1 # homeassistant.components.doorbird doorbirdpy==2.1.0 +# homeassistant.components.dremel_3d_printer +dremel3dpy==2.1.1 + # homeassistant.components.dsmr dsmr_parser==0.33 diff --git a/tests/components/dremel_3d_printer/__init__.py b/tests/components/dremel_3d_printer/__init__.py new file mode 100644 index 00000000000..90da6ee929b --- /dev/null +++ b/tests/components/dremel_3d_printer/__init__.py @@ -0,0 +1 @@ +"""Tests for the Dremel 3D Printer integration.""" diff --git a/tests/components/dremel_3d_printer/conftest.py b/tests/components/dremel_3d_printer/conftest.py new file mode 100644 index 00000000000..8df59a2e64a --- /dev/null +++ b/tests/components/dremel_3d_printer/conftest.py @@ -0,0 +1,58 @@ +"""Configure tests for the Dremel 3D Printer integration.""" +from http import HTTPStatus +from unittest.mock import patch + +import pytest +import requests_mock + +from homeassistant.components.dremel_3d_printer.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + +HOST = "1.2.3.4" +CONF_DATA = {CONF_HOST: HOST} + + +def create_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create fixture for adding config entry in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA, unique_id="123456789") + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Add config entry in Home Assistant.""" + return create_entry(hass) + + +@pytest.fixture +def connection() -> None: + """Mock Dremel 3D Printer connection.""" + mock = requests_mock.Mocker() + mock.post( + f"http://{HOST}:80/command", + response_list=[ + {"text": load_fixture("dremel_3d_printer/command_1.json")}, + {"text": load_fixture("dremel_3d_printer/command_2.json")}, + {"text": load_fixture("dremel_3d_printer/command_1.json")}, + {"text": load_fixture("dremel_3d_printer/command_2.json")}, + ], + ) + + mock.post( + f"https://{HOST}:11134/getHomeMessage", + text=load_fixture("dremel_3d_printer/get_home_message.json"), + status_code=HTTPStatus.OK, + ) + mock.start() + + +def patch_async_setup_entry(): + """Patch the async entry setup of Dremel 3D Printer.""" + return patch( + "homeassistant.components.dremel_3d_printer.async_setup_entry", + return_value=True, + ) diff --git a/tests/components/dremel_3d_printer/fixtures/command_1.json b/tests/components/dremel_3d_printer/fixtures/command_1.json new file mode 100644 index 00000000000..4e61b40ea46 --- /dev/null +++ b/tests/components/dremel_3d_printer/fixtures/command_1.json @@ -0,0 +1,12 @@ +{ + "SN": "123456789", + "api_version": "1.0.2-alpha", + "error_code": 200, + "ethernet_connected": 1, + "ethernet_ip": "1.2.3.4", + "firmware_version": "v3.0_R02.12.10", + "machine_type": "DREMEL 3D45 IDEA BUILDER", + "message": "success", + "wifi_connected": 1, + "wifi_ip": "1.2.3.5" +} diff --git a/tests/components/dremel_3d_printer/fixtures/command_2.json b/tests/components/dremel_3d_printer/fixtures/command_2.json new file mode 100644 index 00000000000..fc73b10cb57 --- /dev/null +++ b/tests/components/dremel_3d_printer/fixtures/command_2.json @@ -0,0 +1,22 @@ +{ + "buildPlate_target_temperature": 60, + "chamber_temperature": 27, + "door_open": 0, + "elaspedtime": 0, + "error_code": 200, + "extruder_target_temperature": 230, + "fanSpeed": 0, + "filament_type ": "ECO-ABS", + "firmware_version": "v3.0_R02.12.10", + "jobname": "D32_Imperial_Credit.gcode", + "jobstatus": "building", + "layer": 0, + "message": "success", + "networkBuild": 1, + "platform_temperature": 60, + "progress": 13.9, + "remaining": 3736, + "status": "busy", + "temperature": 230, + "totalTime": 4340 +} diff --git a/tests/components/dremel_3d_printer/fixtures/get_home_message.json b/tests/components/dremel_3d_printer/fixtures/get_home_message.json new file mode 100644 index 00000000000..1a79210c35d --- /dev/null +++ b/tests/components/dremel_3d_printer/fixtures/get_home_message.json @@ -0,0 +1,26 @@ +{ + "BedTemp": 60, + "BedTempTarget": 60, + "ErrorCode": 200, + "FilamentType": 2, + "FirwareVersion": "v3.0_R02.12.10", + "Message": "success", + "NozzleTemp": 230, + "NozzleTempTarget": 230, + "PreheatBed": 0, + "PreheatNozzle": 0, + "PrinterBedMessage": "Bed 0-100 ℃", + "PrinterCamera": "http://1.2.3.4:10123/?action=stream", + "PrinterFiles": 10, + "PrinterMicrons": "50-300 microns", + "PrinterName": "DREMEL DIGILAB 3D45", + "PrinterNozzleMessage": "Nozzle 0-280 ℃", + "PrinterStatus": "printing", + "PrintererAvailabelStorage": 87, + "PrintingFileName": "D32_Imperial_Credit.gcode", + "PrintingFilePic": "/tmp/mnt/dev/mmcblk0p3/modelFromDevice/pic/D32_Imperial_Credit_gcode.bmp", + "PrintingProgress": 13.9, + "RemainTime": 3736, + "SerialNumber": "123456789", + "UsageCounter": "7" +} diff --git a/tests/components/dremel_3d_printer/test_config_flow.py b/tests/components/dremel_3d_printer/test_config_flow.py new file mode 100644 index 00000000000..8161662a14a --- /dev/null +++ b/tests/components/dremel_3d_printer/test_config_flow.py @@ -0,0 +1,87 @@ +"""Test Dremel 3D Printer config flow.""" +from unittest.mock import patch + +from requests.exceptions import ConnectTimeout + +from homeassistant import data_entry_flow +from homeassistant.components.dremel_3d_printer.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant + +from .conftest import CONF_DATA, patch_async_setup_entry + +from tests.common import MockConfigEntry + +MOCK = "homeassistant.components.dremel_3d_printer.config_flow.Dremel3DPrinter" + + +async def test_full_user_flow_implementation(hass: HomeAssistant, connection) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "DREMEL 3D45" + assert result["data"] == CONF_DATA + + +async def test_already_configured( + hass: HomeAssistant, connection, config_entry: MockConfigEntry +) -> None: + """Test we abort if the device is already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_cannot_connect(hass: HomeAssistant, connection) -> None: + """Test we show user form on connection error.""" + with patch(MOCK, side_effect=ConnectTimeout): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == CONF_DATA + + +async def test_unknown_error(hass: HomeAssistant, connection) -> None: + """Test we show user form on unknown error.""" + with patch(MOCK, side_effect=Exception): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "DREMEL 3D45" + assert result["data"] == CONF_DATA diff --git a/tests/components/dremel_3d_printer/test_init.py b/tests/components/dremel_3d_printer/test_init.py new file mode 100644 index 00000000000..5d97c89b9cd --- /dev/null +++ b/tests/components/dremel_3d_printer/test_init.py @@ -0,0 +1,80 @@ +"""Test Dremel 3D Printer integration.""" +from datetime import timedelta +from unittest.mock import patch + +from requests.exceptions import ConnectTimeout + +from homeassistant.components.dremel_3d_printer.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_setup( + hass: HomeAssistant, connection, config_entry: MockConfigEntry +) -> None: + """Test load and unload.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) + assert config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_not_ready( + hass: HomeAssistant, connection, config_entry: MockConfigEntry +) -> None: + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + with patch( + "homeassistant.components.dremel_3d_printer.Dremel3DPrinter", + side_effect=ConnectTimeout, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + + +async def test_update_failed( + hass: HomeAssistant, connection, config_entry: MockConfigEntry +) -> None: + """Test coordinator throws UpdateFailed after failed update.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) + assert config_entry.state == ConfigEntryState.LOADED + + with patch( + "homeassistant.components.dremel_3d_printer.Dremel3DPrinter.refresh", + side_effect=RuntimeError, + ) as updater: + next_update = dt_util.utcnow() + timedelta(seconds=10) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + updater.assert_called_once() + state = hass.states.get("sensor.dremel_3d45_job_phase") + assert state.state == STATE_UNAVAILABLE + + +async def test_device_info( + hass: HomeAssistant, connection, config_entry: MockConfigEntry +) -> None: + """Test device info.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, config_entry.unique_id)}) + + assert device.manufacturer == "Dremel" + assert device.model == "3D45" + assert device.name == "DREMEL 3D45" + assert device.sw_version == "v3.0_R02.12.10" diff --git a/tests/components/dremel_3d_printer/test_sensor.py b/tests/components/dremel_3d_printer/test_sensor.py new file mode 100644 index 00000000000..b38a5feff36 --- /dev/null +++ b/tests/components/dremel_3d_printer/test_sensor.py @@ -0,0 +1,110 @@ +"""Sensor tests for the Dremel 3D Printer integration.""" +from datetime import datetime +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.dremel_3d_printer.const import DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfInformation, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import UTC + +from tests.common import MockConfigEntry + + +async def test_sensors( + hass: HomeAssistant, + connection, + config_entry: MockConfigEntry, + entity_registry_enabled_by_default: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we get sensor data.""" + freezer.move_to(datetime(2023, 5, 31, 13, 30, tzinfo=UTC)) + await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, DOMAIN, {}) + state = hass.states.get("sensor.dremel_3d45_job_phase") + assert state.state == "building" + state = hass.states.get("sensor.dremel_3d45_remaining_time") + assert state.state == "2023-05-31T12:27:44+00:00" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.dremel_3d45_progress") + assert state.state == "13.9" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is PERCENTAGE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_chamber") + assert state.state == "27" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_platform_temperature") + assert state.state == "60" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_target_platform_temperature") + assert state.state == "60" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_max_platform_temperature") + assert state.state == "100" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_extruder") + assert state.state == "230" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_target_extruder_temperature") + assert state.state == "230" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_max_extruder_temperature") + assert state.state == "280" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.dremel_3d45_network_build") + assert state.state == "1" + state = hass.states.get("sensor.dremel_3d45_filament") + assert state.state == "ECO-ABS" + state = hass.states.get("sensor.dremel_3d45_elapsed_time") + assert state.state == "2023-05-31T13:30:00+00:00" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.dremel_3d45_estimated_total_time") + assert state.state == "2023-05-31T12:17:40+00:00" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.dremel_3d45_job_status") + assert state.state == "building" + state = hass.states.get("sensor.dremel_3d45_job_name") + assert state.state == "D32_Imperial_Credit" + state = hass.states.get("sensor.dremel_3d45_api_version") + assert state.state == "1.0.2-alpha" + state = hass.states.get("sensor.dremel_3d45_host") + assert state.state == "1.2.3.4" + state = hass.states.get("sensor.dremel_3d45_connection_type") + assert state.state == "eth0" + state = hass.states.get("sensor.dremel_3d45_available_storage") + assert state.state == "8700" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfInformation.MEGABYTES + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATA_SIZE + state = hass.states.get("sensor.dremel_3d45_hours_used") + assert state.state == "7" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UnitOfTime.HOURS + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DURATION