diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index e8c930c3157..2ff4601466c 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -23,8 +23,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -44,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryError("Please upgrade your printer's firmware.") api = PrusaLink( - async_get_clientsession(hass), + get_async_client(hass), entry.data[CONF_HOST], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], @@ -81,7 +81,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> password = config_entry.data[CONF_API_KEY] api = PrusaLink( - async_get_clientsession(hass), + get_async_client(hass), config_entry.data[CONF_HOST], username, password, diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index 48c2fee9cdb..cc625b7ef57 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pyprusalink.types import PrinterState + from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -38,6 +40,7 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera): """Get if camera is available.""" return ( super().available + and self.coordinator.data.get("state") != PrinterState.IDLE.value and (file := self.coordinator.data.get("file")) and file.get("refs", {}).get("thumbnail") ) diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index c0c8c797133..b0c7cf2f756 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -6,17 +6,17 @@ import asyncio import logging from typing import Any -from aiohttp import ClientError from awesomeversion import AwesomeVersion, AwesomeVersionException +from httpx import HTTPError, InvalidURL from pyprusalink import PrusaLink -from pyprusalink.types import InvalidAuth +from pyprusalink.types import InvalidAuth, VersionInfo import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.httpx_client import get_async_client from .const import DOMAIN @@ -34,13 +34,33 @@ STEP_USER_DATA_SCHEMA = vol.Schema( ) +def ensure_printer_is_supported(version: VersionInfo) -> None: + """Raise NotSupported exception if the printer is not supported.""" + + try: + if AwesomeVersion("2.0.0") <= AwesomeVersion(version["api"]): + return + + # Workaround to allow PrusaLink 0.7.2 on MK3 and MK2.5 that supports + # the 2.0.0 API, but doesn't advertise it yet + if version.get("original", "").startswith( + ("PrusaLink I3MK3", "PrusaLink I3MK2") + ) and AwesomeVersion("0.7.2") <= AwesomeVersion(version["server"]): + return + + except AwesomeVersionException as err: + raise NotSupported from err + + raise NotSupported + + async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, str]: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ api = PrusaLink( - async_get_clientsession(hass), + get_async_client(hass), data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD], @@ -50,15 +70,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, async with asyncio.timeout(5): version = await api.get_version() - except (TimeoutError, ClientError) as err: + except (TimeoutError, HTTPError, InvalidURL) as err: _LOGGER.error("Could not connect to PrusaLink: %s", err) raise CannotConnect from err - try: - if AwesomeVersion(version["api"]) < AwesomeVersion("2.0.0"): - raise NotSupported - except AwesomeVersionException as err: - raise NotSupported from err + ensure_printer_is_supported(version) return {"title": version["hostname"] or version["text"]} diff --git a/homeassistant/components/prusalink/manifest.json b/homeassistant/components/prusalink/manifest.json index a9d8353690e..6c64419debb 100644 --- a/homeassistant/components/prusalink/manifest.json +++ b/homeassistant/components/prusalink/manifest.json @@ -10,5 +10,5 @@ ], "documentation": "https://www.home-assistant.io/integrations/prusalink", "iot_class": "local_polling", - "requirements": ["pyprusalink==2.0.0"] + "requirements": ["pyprusalink==2.1.1"] } diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index be4230b844c..604b029fc92 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -146,13 +146,15 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { translation_key="progress", native_unit_of_measurement=PERCENTAGE, value_fn=lambda data: cast(float, data["progress"]), - available_fn=lambda data: data.get("progress") is not None, + available_fn=lambda data: data.get("progress") is not None + and data.get("state") != PrinterState.IDLE.value, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.filename", translation_key="filename", value_fn=lambda data: cast(str, data["file"]["display_name"]), - available_fn=lambda data: data.get("file") is not None, + available_fn=lambda data: data.get("file") is not None + and data.get("state") != PrinterState.IDLE.value, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.start", @@ -162,7 +164,8 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { lambda data: (utcnow() - timedelta(seconds=data["time_printing"])), timedelta(minutes=2), ), - available_fn=lambda data: data.get("time_printing") is not None, + available_fn=lambda data: data.get("time_printing") is not None + and data.get("state") != PrinterState.IDLE.value, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.finish", @@ -172,7 +175,8 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { lambda data: (utcnow() + timedelta(seconds=data["time_remaining"])), timedelta(minutes=2), ), - available_fn=lambda data: data.get("time_remaining") is not None, + available_fn=lambda data: data.get("time_remaining") is not None + and data.get("state") != PrinterState.IDLE.value, ), ), } diff --git a/requirements_all.txt b/requirements_all.txt index fd87aa7592d..8284e9ea81b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2065,7 +2065,7 @@ pyprof2calltree==1.4.5 pyprosegur==0.0.9 # homeassistant.components.prusalink -pyprusalink==2.0.0 +pyprusalink==2.1.1 # homeassistant.components.ps4 pyps4-2ndscreen==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7789460ea4e..47dc312e903 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1610,7 +1610,7 @@ pyprof2calltree==1.4.5 pyprosegur==0.0.9 # homeassistant.components.prusalink -pyprusalink==2.0.0 +pyprusalink==2.1.1 # homeassistant.components.ps4 pyps4-2ndscreen==1.3.1 diff --git a/tests/components/prusalink/conftest.py b/tests/components/prusalink/conftest.py index f99ccde2094..104e4d47afa 100644 --- a/tests/components/prusalink/conftest.py +++ b/tests/components/prusalink/conftest.py @@ -121,6 +121,32 @@ def mock_job_api_idle(hass): yield resp +@pytest.fixture +def mock_job_api_idle_mk3(hass): + """Mock PrusaLink job API having a job with idle state (MK3).""" + resp = { + "id": 129, + "state": "IDLE", + "progress": 0.0, + "time_remaining": None, + "time_printing": 0, + "file": { + "refs": { + "icon": "/thumb/s/usb/TabletStand3~4.BGC", + "thumbnail": "/thumb/l/usb/TabletStand3~4.BGC", + "download": "/usb/TabletStand3~4.BGC", + }, + "name": "TabletStand3~4.BGC", + "display_name": "TabletStand3.bgcode", + "path": "/usb", + "size": 754535, + "m_timestamp": 1698686881, + }, + } + with patch("pyprusalink.PrusaLink.get_job", return_value=resp): + yield resp + + @pytest.fixture def mock_job_api_printing(hass): """Mock PrusaLink printing.""" diff --git a/tests/components/prusalink/test_camera.py b/tests/components/prusalink/test_camera.py index 94d9c2f8271..c770a7f228d 100644 --- a/tests/components/prusalink/test_camera.py +++ b/tests/components/prusalink/test_camera.py @@ -35,6 +35,24 @@ async def test_camera_no_job( assert resp.status == 500 +async def test_camera_idle_job_mk3( + hass: HomeAssistant, + mock_config_entry, + mock_api, + mock_job_api_idle_mk3, + hass_client: ClientSessionGenerator, +) -> None: + """Test camera while job state is idle (MK3).""" + assert await async_setup_component(hass, "prusalink", {}) + state = hass.states.get("camera.mock_title_preview") + assert state is not None + assert state.state == "unavailable" + + client = await hass_client() + resp = await client.get("/api/camera_proxy/camera.mock_title_preview") + assert resp.status == 500 + + async def test_camera_active_job( hass: HomeAssistant, mock_config_entry, diff --git a/tests/components/prusalink/test_config_flow.py b/tests/components/prusalink/test_config_flow.py index 43f969182b9..e7db5b54dac 100644 --- a/tests/components/prusalink/test_config_flow.py +++ b/tests/components/prusalink/test_config_flow.py @@ -41,6 +41,34 @@ async def test_form(hass: HomeAssistant, mock_version_api) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_mk3(hass: HomeAssistant, mock_version_api) -> None: + """Test it works for MK2/MK3.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_version_api["api"] = "0.9.0-legacy" + mock_version_api["server"] = "0.7.2" + mock_version_api["original"] = "PrusaLink I3MK3S" + + with patch( + "homeassistant.components.prusalink.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "http://1.1.1.1/", + "username": "abcdefg", + "password": "abcdefg", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -129,6 +157,31 @@ async def test_form_invalid_version_2(hass: HomeAssistant, mock_version_api) -> assert result2["errors"] == {"base": "not_supported"} +async def test_form_invalid_mk3_server_version( + hass: HomeAssistant, mock_version_api +) -> None: + """Test we handle invalid version for MK2/MK3.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_version_api["api"] = "0.7.2" + mock_version_api["server"] = "i am not a version" + mock_version_api["original"] = "PrusaLink I3MK3S" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "abcdefg", + "password": "abcdefg", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "not_supported"} + + async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/prusalink/test_sensor.py b/tests/components/prusalink/test_sensor.py index bba06f66146..b15e9198da6 100644 --- a/tests/components/prusalink/test_sensor.py +++ b/tests/components/prusalink/test_sensor.py @@ -136,6 +136,110 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE +async def test_sensors_idle_job_mk3( + hass: HomeAssistant, + mock_config_entry, + mock_api, + mock_job_api_idle_mk3, +) -> None: + """Test sensors while job state is idle (MK3).""" + assert await async_setup_component(hass, "prusalink", {}) + + state = hass.states.get("sensor.mock_title") + assert state is not None + assert state.state == "idle" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert state.attributes[ATTR_OPTIONS] == [ + "idle", + "busy", + "printing", + "paused", + "finished", + "stopped", + "error", + "attention", + "ready", + ] + + state = hass.states.get("sensor.mock_title_heatbed_temperature") + assert state is not None + assert state.state == "41.9" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + state = hass.states.get("sensor.mock_title_nozzle_temperature") + assert state is not None + assert state.state == "47.8" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + state = hass.states.get("sensor.mock_title_heatbed_target_temperature") + assert state is not None + assert state.state == "60.5" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + state = hass.states.get("sensor.mock_title_nozzle_target_temperature") + assert state is not None + assert state.state == "210.1" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + state = hass.states.get("sensor.mock_title_z_height") + assert state is not None + assert state.state == "1.8" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfLength.MILLIMETERS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.DISTANCE + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + state = hass.states.get("sensor.mock_title_print_speed") + assert state is not None + assert state.state == "100" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + + state = hass.states.get("sensor.mock_title_material") + assert state is not None + assert state.state == "PLA" + + state = hass.states.get("sensor.mock_title_print_flow") + assert state is not None + assert state.state == "100" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + + state = hass.states.get("sensor.mock_title_progress") + assert state is not None + assert state.state == "unavailable" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "%" + + state = hass.states.get("sensor.mock_title_filename") + assert state is not None + assert state.state == "unavailable" + + state = hass.states.get("sensor.mock_title_print_start") + assert state is not None + assert state.state == "unavailable" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + + state = hass.states.get("sensor.mock_title_print_finish") + assert state is not None + assert state.state == "unavailable" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + + state = hass.states.get("sensor.mock_title_hotend_fan") + assert state is not None + assert state.state == "100" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE + + state = hass.states.get("sensor.mock_title_print_fan") + assert state is not None + assert state.state == "75" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE + + async def test_sensors_active_job( hass: HomeAssistant, mock_config_entry,