Bump pyprusalink to 2.1.1 and support Prusa MK3 (#114210)

Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Tereza Tomcova 2024-03-27 15:26:44 +01:00 committed by GitHub
parent 1a64be6da6
commit a00c1fa241
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 244 additions and 20 deletions

View File

@ -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,

View File

@ -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")
)

View File

@ -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"]}

View File

@ -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"]
}

View File

@ -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,
),
),
}

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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,

View File

@ -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(

View File

@ -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,