mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 12:47:08 +00:00
Upgrade Prusa Link to new Digest Authentication and /v1/ API (#103396)
Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
parent
c226d793d4
commit
91f8d3faef
@ -993,8 +993,8 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/proximity/ @mib1185
|
/homeassistant/components/proximity/ @mib1185
|
||||||
/tests/components/proximity/ @mib1185
|
/tests/components/proximity/ @mib1185
|
||||||
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno
|
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno
|
||||||
/homeassistant/components/prusalink/ @balloob
|
/homeassistant/components/prusalink/ @balloob @Skaronator
|
||||||
/tests/components/prusalink/ @balloob
|
/tests/components/prusalink/ @balloob @Skaronator
|
||||||
/homeassistant/components/ps4/ @ktnrg45
|
/homeassistant/components/ps4/ @ktnrg45
|
||||||
/tests/components/ps4/ @ktnrg45
|
/tests/components/ps4/ @ktnrg45
|
||||||
/homeassistant/components/pure_energie/ @klaasnicolaas
|
/homeassistant/components/pure_energie/ @klaasnicolaas
|
||||||
|
@ -6,13 +6,21 @@ import asyncio
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Generic, TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
from pyprusalink import InvalidAuth, JobInfo, PrinterInfo, PrusaLink, PrusaLinkError
|
from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink
|
||||||
|
from pyprusalink.types import InvalidAuth, PrusaLinkError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import (
|
||||||
|
CONF_API_KEY,
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_USERNAME,
|
||||||
|
Platform,
|
||||||
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import (
|
from homeassistant.helpers.update_coordinator import (
|
||||||
@ -27,16 +35,71 @@ PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.CAMERA, Platform.SENSOR]
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def _migrate_to_version_2(
|
||||||
"""Set up PrusaLink from a config entry."""
|
hass: HomeAssistant, entry: ConfigEntry
|
||||||
|
) -> PrusaLink | None:
|
||||||
|
"""Migrate to Version 2."""
|
||||||
|
_LOGGER.debug("Migrating entry to version 2")
|
||||||
|
|
||||||
|
data = dict(entry.data)
|
||||||
|
# "maker" is currently hardcoded in the firmware
|
||||||
|
# https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19
|
||||||
|
data = {
|
||||||
|
**entry.data,
|
||||||
|
CONF_USERNAME: "maker",
|
||||||
|
CONF_PASSWORD: entry.data[CONF_API_KEY],
|
||||||
|
}
|
||||||
|
data.pop(CONF_API_KEY)
|
||||||
|
|
||||||
api = PrusaLink(
|
api = PrusaLink(
|
||||||
async_get_clientsession(hass),
|
async_get_clientsession(hass),
|
||||||
entry.data["host"],
|
data[CONF_HOST],
|
||||||
entry.data["api_key"],
|
data[CONF_USERNAME],
|
||||||
|
data[CONF_PASSWORD],
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await api.get_info()
|
||||||
|
except InvalidAuth:
|
||||||
|
# We are unable to reach the new API which usually means
|
||||||
|
# that the user is running an outdated firmware version
|
||||||
|
ir.async_create_issue(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
"firmware_5_1_required",
|
||||||
|
is_fixable=False,
|
||||||
|
severity=ir.IssueSeverity.ERROR,
|
||||||
|
translation_key="firmware_5_1_required",
|
||||||
|
translation_placeholders={
|
||||||
|
"entry_title": entry.title,
|
||||||
|
"prusa_mini_firmware_update": "https://help.prusa3d.com/article/firmware-updating-mini-mini_124784",
|
||||||
|
"prusa_mk4_xl_firmware_update": "https://help.prusa3d.com/article/how-to-update-firmware-mk4-xl_453086",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
entry.version = 2
|
||||||
|
hass.config_entries.async_update_entry(entry, data=data)
|
||||||
|
_LOGGER.info("Migrated config entry to version %d", entry.version)
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up PrusaLink from a config entry."""
|
||||||
|
if entry.version == 1:
|
||||||
|
if (api := await _migrate_to_version_2(hass, entry)) is None:
|
||||||
|
return False
|
||||||
|
ir.async_delete_issue(hass, DOMAIN, "firmware_5_1_required")
|
||||||
|
else:
|
||||||
|
api = PrusaLink(
|
||||||
|
async_get_clientsession(hass),
|
||||||
|
entry.data[CONF_HOST],
|
||||||
|
entry.data[CONF_USERNAME],
|
||||||
|
entry.data[CONF_PASSWORD],
|
||||||
|
)
|
||||||
|
|
||||||
coordinators = {
|
coordinators = {
|
||||||
"printer": PrinterUpdateCoordinator(hass, api),
|
"legacy_status": LegacyStatusCoordinator(hass, api),
|
||||||
|
"status": StatusCoordinator(hass, api),
|
||||||
"job": JobUpdateCoordinator(hass, api),
|
"job": JobUpdateCoordinator(hass, api),
|
||||||
}
|
}
|
||||||
for coordinator in coordinators.values():
|
for coordinator in coordinators.values():
|
||||||
@ -49,6 +112,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Migrate old entry."""
|
||||||
|
# Version 1->2 migration are handled in async_setup_entry.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
@ -57,10 +126,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T", PrinterInfo, JobInfo)
|
T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo)
|
||||||
|
|
||||||
|
|
||||||
class PrusaLinkUpdateCoordinator(DataUpdateCoordinator, Generic[T], ABC):
|
class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC):
|
||||||
"""Update coordinator for the printer."""
|
"""Update coordinator for the printer."""
|
||||||
|
|
||||||
config_entry: ConfigEntry
|
config_entry: ConfigEntry
|
||||||
@ -105,21 +174,20 @@ class PrusaLinkUpdateCoordinator(DataUpdateCoordinator, Generic[T], ABC):
|
|||||||
return timedelta(seconds=30)
|
return timedelta(seconds=30)
|
||||||
|
|
||||||
|
|
||||||
class PrinterUpdateCoordinator(PrusaLinkUpdateCoordinator[PrinterInfo]):
|
class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]):
|
||||||
"""Printer update coordinator."""
|
"""Printer update coordinator."""
|
||||||
|
|
||||||
async def _fetch_data(self) -> PrinterInfo:
|
async def _fetch_data(self) -> PrinterStatus:
|
||||||
"""Fetch the printer data."""
|
"""Fetch the printer data."""
|
||||||
return await self.api.get_printer()
|
return await self.api.get_status()
|
||||||
|
|
||||||
def _get_update_interval(self, data: T) -> timedelta:
|
|
||||||
"""Get new update interval."""
|
|
||||||
if data and any(
|
|
||||||
data["state"]["flags"][key] for key in ("pausing", "cancelling")
|
|
||||||
):
|
|
||||||
return timedelta(seconds=5)
|
|
||||||
|
|
||||||
return super()._get_update_interval(data)
|
class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]):
|
||||||
|
"""Printer legacy update coordinator."""
|
||||||
|
|
||||||
|
async def _fetch_data(self) -> LegacyPrinterStatus:
|
||||||
|
"""Fetch the printer data."""
|
||||||
|
return await self.api.get_legacy_printer()
|
||||||
|
|
||||||
|
|
||||||
class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]):
|
class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]):
|
||||||
@ -142,5 +210,5 @@ class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]):
|
|||||||
identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)},
|
identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)},
|
||||||
name=self.coordinator.config_entry.title,
|
name=self.coordinator.config_entry.title,
|
||||||
manufacturer="Prusa",
|
manufacturer="Prusa",
|
||||||
configuration_url=self.coordinator.api.host,
|
configuration_url=self.coordinator.api.client.host,
|
||||||
)
|
)
|
||||||
|
@ -5,7 +5,8 @@ from collections.abc import Callable, Coroutine
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Generic, TypeVar, cast
|
from typing import Any, Generic, TypeVar, cast
|
||||||
|
|
||||||
from pyprusalink import Conflict, JobInfo, PrinterInfo, PrusaLink
|
from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink
|
||||||
|
from pyprusalink.types import Conflict, PrinterState
|
||||||
|
|
||||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@ -15,14 +16,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
|
|
||||||
from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator
|
from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator
|
||||||
|
|
||||||
T = TypeVar("T", PrinterInfo, JobInfo)
|
T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PrusaLinkButtonEntityDescriptionMixin(Generic[T]):
|
class PrusaLinkButtonEntityDescriptionMixin(Generic[T]):
|
||||||
"""Mixin for required keys."""
|
"""Mixin for required keys."""
|
||||||
|
|
||||||
press_fn: Callable[[PrusaLink], Coroutine[Any, Any, None]]
|
press_fn: Callable[[PrusaLink], Callable[[int], Coroutine[Any, Any, None]]]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -35,33 +36,34 @@ class PrusaLinkButtonEntityDescription(
|
|||||||
|
|
||||||
|
|
||||||
BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = {
|
BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = {
|
||||||
"printer": (
|
"status": (
|
||||||
PrusaLinkButtonEntityDescription[PrinterInfo](
|
PrusaLinkButtonEntityDescription[PrinterStatus](
|
||||||
key="printer.cancel_job",
|
key="printer.cancel_job",
|
||||||
translation_key="cancel_job",
|
translation_key="cancel_job",
|
||||||
icon="mdi:cancel",
|
icon="mdi:cancel",
|
||||||
press_fn=lambda api: cast(Coroutine, api.cancel_job()),
|
press_fn=lambda api: api.cancel_job,
|
||||||
available_fn=lambda data: any(
|
available_fn=lambda data: (
|
||||||
data["state"]["flags"][flag]
|
data["printer"]["state"]
|
||||||
for flag in ("printing", "pausing", "paused")
|
in [PrinterState.PRINTING.value, PrinterState.PAUSED.value]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PrusaLinkButtonEntityDescription[PrinterInfo](
|
PrusaLinkButtonEntityDescription[PrinterStatus](
|
||||||
key="job.pause_job",
|
key="job.pause_job",
|
||||||
translation_key="pause_job",
|
translation_key="pause_job",
|
||||||
icon="mdi:pause",
|
icon="mdi:pause",
|
||||||
press_fn=lambda api: cast(Coroutine, api.pause_job()),
|
press_fn=lambda api: api.pause_job,
|
||||||
available_fn=lambda data: (
|
available_fn=lambda data: cast(
|
||||||
data["state"]["flags"]["printing"]
|
bool, data["printer"]["state"] == PrinterState.PRINTING.value
|
||||||
and not data["state"]["flags"]["paused"]
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PrusaLinkButtonEntityDescription[PrinterInfo](
|
PrusaLinkButtonEntityDescription[PrinterStatus](
|
||||||
key="job.resume_job",
|
key="job.resume_job",
|
||||||
translation_key="resume_job",
|
translation_key="resume_job",
|
||||||
icon="mdi:play",
|
icon="mdi:play",
|
||||||
press_fn=lambda api: cast(Coroutine, api.resume_job()),
|
press_fn=lambda api: api.resume_job,
|
||||||
available_fn=lambda data: cast(bool, data["state"]["flags"]["paused"]),
|
available_fn=lambda data: cast(
|
||||||
|
bool, data["printer"]["state"] == PrinterState.PAUSED.value
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@ -113,8 +115,10 @@ class PrusaLinkButtonEntity(PrusaLinkEntity, ButtonEntity):
|
|||||||
|
|
||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
|
job_id = self.coordinator.data["job"]["id"]
|
||||||
|
func = self.entity_description.press_fn(self.coordinator.api)
|
||||||
try:
|
try:
|
||||||
await self.entity_description.press_fn(self.coordinator.api)
|
await func(job_id)
|
||||||
except Conflict as err:
|
except Conflict as err:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
"Action conflicts with current printer state"
|
"Action conflicts with current printer state"
|
||||||
|
@ -35,7 +35,11 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera):
|
|||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Get if camera is available."""
|
"""Get if camera is available."""
|
||||||
return super().available and self.coordinator.data.get("job") is not None
|
return (
|
||||||
|
super().available
|
||||||
|
and (file := self.coordinator.data.get("file"))
|
||||||
|
and file.get("refs", {}).get("thumbnail")
|
||||||
|
)
|
||||||
|
|
||||||
async def async_camera_image(
|
async def async_camera_image(
|
||||||
self, width: int | None = None, height: int | None = None
|
self, width: int | None = None, height: int | None = None
|
||||||
@ -44,11 +48,11 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera):
|
|||||||
if not self.available:
|
if not self.available:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
path = self.coordinator.data["job"]["file"]["path"]
|
path = self.coordinator.data["file"]["refs"]["thumbnail"]
|
||||||
|
|
||||||
if self.last_path == path:
|
if self.last_path == path:
|
||||||
return self.last_image
|
return self.last_image
|
||||||
|
|
||||||
self.last_image = await self.coordinator.api.get_large_thumbnail(path)
|
self.last_image = await self.coordinator.api.get_file(path)
|
||||||
self.last_path = path
|
self.last_path = path
|
||||||
return self.last_image
|
return self.last_image
|
||||||
|
@ -7,11 +7,12 @@ from typing import Any
|
|||||||
|
|
||||||
from aiohttp import ClientError
|
from aiohttp import ClientError
|
||||||
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
||||||
from pyprusalink import InvalidAuth, PrusaLink
|
from pyprusalink import PrusaLink
|
||||||
|
from pyprusalink.types import InvalidAuth
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_HOST
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
@ -25,7 +26,10 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_HOST): str,
|
vol.Required(CONF_HOST): str,
|
||||||
vol.Required(CONF_API_KEY): str,
|
# "maker" is currently hardcoded in the firmware
|
||||||
|
# https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19
|
||||||
|
vol.Required(CONF_USERNAME, default="maker"): str,
|
||||||
|
vol.Required(CONF_PASSWORD): str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -35,7 +39,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str,
|
|||||||
|
|
||||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||||
"""
|
"""
|
||||||
api = PrusaLink(async_get_clientsession(hass), data[CONF_HOST], data[CONF_API_KEY])
|
api = PrusaLink(
|
||||||
|
async_get_clientsession(hass),
|
||||||
|
data[CONF_HOST],
|
||||||
|
data[CONF_USERNAME],
|
||||||
|
data[CONF_PASSWORD],
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with asyncio.timeout(5):
|
async with asyncio.timeout(5):
|
||||||
@ -57,7 +66,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str,
|
|||||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for PrusaLink."""
|
"""Handle a config flow for PrusaLink."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 2
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
@ -74,7 +83,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
data = {
|
data = {
|
||||||
CONF_HOST: host,
|
CONF_HOST: host,
|
||||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||||
|
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||||
}
|
}
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "prusalink",
|
"domain": "prusalink",
|
||||||
"name": "PrusaLink",
|
"name": "PrusaLink",
|
||||||
"codeowners": ["@balloob"],
|
"codeowners": ["@balloob", "@Skaronator"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dhcp": [
|
"dhcp": [
|
||||||
{
|
{
|
||||||
@ -10,5 +10,5 @@
|
|||||||
],
|
],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/prusalink",
|
"documentation": "https://www.home-assistant.io/integrations/prusalink",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["pyprusalink==1.1.0"]
|
"requirements": ["pyprusalink==2.0.0"]
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,8 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Generic, TypeVar, cast
|
from typing import Generic, TypeVar, cast
|
||||||
|
|
||||||
from pyprusalink import JobInfo, PrinterInfo
|
from pyprusalink.types import JobInfo, PrinterState, PrinterStatus
|
||||||
|
from pyprusalink.types_legacy import LegacyPrinterStatus
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
@ -15,7 +16,12 @@ from homeassistant.components.sensor import (
|
|||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfTemperature
|
from homeassistant.const import (
|
||||||
|
PERCENTAGE,
|
||||||
|
REVOLUTIONS_PER_MINUTE,
|
||||||
|
UnitOfLength,
|
||||||
|
UnitOfTemperature,
|
||||||
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
@ -24,7 +30,7 @@ from homeassistant.util.variance import ignore_variance
|
|||||||
|
|
||||||
from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator
|
from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator
|
||||||
|
|
||||||
T = TypeVar("T", PrinterInfo, JobInfo)
|
T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -44,78 +50,91 @@ class PrusaLinkSensorEntityDescription(
|
|||||||
|
|
||||||
|
|
||||||
SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = {
|
SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = {
|
||||||
"printer": (
|
"status": (
|
||||||
PrusaLinkSensorEntityDescription[PrinterInfo](
|
PrusaLinkSensorEntityDescription[PrinterStatus](
|
||||||
key="printer.state",
|
key="printer.state",
|
||||||
name=None,
|
name=None,
|
||||||
icon="mdi:printer-3d",
|
icon="mdi:printer-3d",
|
||||||
value_fn=lambda data: (
|
value_fn=lambda data: (cast(str, data["printer"]["state"].lower())),
|
||||||
"pausing"
|
|
||||||
if (flags := data["state"]["flags"])["pausing"]
|
|
||||||
else "cancelling"
|
|
||||||
if flags["cancelling"]
|
|
||||||
else "paused"
|
|
||||||
if flags["paused"]
|
|
||||||
else "printing"
|
|
||||||
if flags["printing"]
|
|
||||||
else "idle"
|
|
||||||
),
|
|
||||||
device_class=SensorDeviceClass.ENUM,
|
device_class=SensorDeviceClass.ENUM,
|
||||||
options=["cancelling", "idle", "paused", "pausing", "printing"],
|
options=[state.value.lower() for state in PrinterState],
|
||||||
translation_key="printer_state",
|
translation_key="printer_state",
|
||||||
),
|
),
|
||||||
PrusaLinkSensorEntityDescription[PrinterInfo](
|
PrusaLinkSensorEntityDescription[PrinterStatus](
|
||||||
key="printer.telemetry.temp-bed",
|
key="printer.telemetry.temp-bed",
|
||||||
translation_key="heatbed_temperature",
|
translation_key="heatbed_temperature",
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value_fn=lambda data: cast(float, data["telemetry"]["temp-bed"]),
|
value_fn=lambda data: cast(float, data["printer"]["temp_bed"]),
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
PrusaLinkSensorEntityDescription[PrinterInfo](
|
PrusaLinkSensorEntityDescription[PrinterStatus](
|
||||||
key="printer.telemetry.temp-nozzle",
|
key="printer.telemetry.temp-nozzle",
|
||||||
translation_key="nozzle_temperature",
|
translation_key="nozzle_temperature",
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value_fn=lambda data: cast(float, data["telemetry"]["temp-nozzle"]),
|
value_fn=lambda data: cast(float, data["printer"]["temp_nozzle"]),
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
PrusaLinkSensorEntityDescription[PrinterInfo](
|
PrusaLinkSensorEntityDescription[PrinterStatus](
|
||||||
key="printer.telemetry.temp-bed.target",
|
key="printer.telemetry.temp-bed.target",
|
||||||
translation_key="heatbed_target_temperature",
|
translation_key="heatbed_target_temperature",
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value_fn=lambda data: cast(float, data["temperature"]["bed"]["target"]),
|
value_fn=lambda data: cast(float, data["printer"]["target_bed"]),
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
PrusaLinkSensorEntityDescription[PrinterInfo](
|
PrusaLinkSensorEntityDescription[PrinterStatus](
|
||||||
key="printer.telemetry.temp-nozzle.target",
|
key="printer.telemetry.temp-nozzle.target",
|
||||||
translation_key="nozzle_target_temperature",
|
translation_key="nozzle_target_temperature",
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value_fn=lambda data: cast(float, data["temperature"]["tool0"]["target"]),
|
value_fn=lambda data: cast(float, data["printer"]["target_nozzle"]),
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
PrusaLinkSensorEntityDescription[PrinterInfo](
|
PrusaLinkSensorEntityDescription[PrinterStatus](
|
||||||
key="printer.telemetry.z-height",
|
key="printer.telemetry.z-height",
|
||||||
translation_key="z_height",
|
translation_key="z_height",
|
||||||
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
|
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
|
||||||
device_class=SensorDeviceClass.DISTANCE,
|
device_class=SensorDeviceClass.DISTANCE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value_fn=lambda data: cast(float, data["telemetry"]["z-height"]),
|
value_fn=lambda data: cast(float, data["printer"]["axis_z"]),
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
PrusaLinkSensorEntityDescription[PrinterInfo](
|
PrusaLinkSensorEntityDescription[PrinterStatus](
|
||||||
key="printer.telemetry.print-speed",
|
key="printer.telemetry.print-speed",
|
||||||
translation_key="print_speed",
|
translation_key="print_speed",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
value_fn=lambda data: cast(float, data["telemetry"]["print-speed"]),
|
value_fn=lambda data: cast(float, data["printer"]["speed"]),
|
||||||
),
|
),
|
||||||
PrusaLinkSensorEntityDescription[PrinterInfo](
|
PrusaLinkSensorEntityDescription[PrinterStatus](
|
||||||
|
key="printer.telemetry.print-flow",
|
||||||
|
translation_key="print_flow",
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
value_fn=lambda data: cast(float, data["printer"]["flow"]),
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
PrusaLinkSensorEntityDescription[PrinterStatus](
|
||||||
|
key="printer.telemetry.fan-hotend",
|
||||||
|
translation_key="fan_hotend",
|
||||||
|
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
|
||||||
|
value_fn=lambda data: cast(float, data["printer"]["fan_hotend"]),
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
PrusaLinkSensorEntityDescription[PrinterStatus](
|
||||||
|
key="printer.telemetry.fan-print",
|
||||||
|
translation_key="fan_print",
|
||||||
|
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
|
||||||
|
value_fn=lambda data: cast(float, data["printer"]["fan_print"]),
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"legacy_status": (
|
||||||
|
PrusaLinkSensorEntityDescription[LegacyPrinterStatus](
|
||||||
key="printer.telemetry.material",
|
key="printer.telemetry.material",
|
||||||
translation_key="material",
|
translation_key="material",
|
||||||
icon="mdi:palette-swatch-variant",
|
icon="mdi:palette-swatch-variant",
|
||||||
@ -128,15 +147,15 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = {
|
|||||||
translation_key="progress",
|
translation_key="progress",
|
||||||
icon="mdi:progress-clock",
|
icon="mdi:progress-clock",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
value_fn=lambda data: cast(float, data["progress"]["completion"]) * 100,
|
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,
|
||||||
),
|
),
|
||||||
PrusaLinkSensorEntityDescription[JobInfo](
|
PrusaLinkSensorEntityDescription[JobInfo](
|
||||||
key="job.filename",
|
key="job.filename",
|
||||||
translation_key="filename",
|
translation_key="filename",
|
||||||
icon="mdi:file-image-outline",
|
icon="mdi:file-image-outline",
|
||||||
value_fn=lambda data: cast(str, data["job"]["file"]["display"]),
|
value_fn=lambda data: cast(str, data["file"]["display_name"]),
|
||||||
available_fn=lambda data: data.get("job") is not None,
|
available_fn=lambda data: data.get("file") is not None,
|
||||||
),
|
),
|
||||||
PrusaLinkSensorEntityDescription[JobInfo](
|
PrusaLinkSensorEntityDescription[JobInfo](
|
||||||
key="job.start",
|
key="job.start",
|
||||||
@ -144,12 +163,10 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = {
|
|||||||
device_class=SensorDeviceClass.TIMESTAMP,
|
device_class=SensorDeviceClass.TIMESTAMP,
|
||||||
icon="mdi:clock-start",
|
icon="mdi:clock-start",
|
||||||
value_fn=ignore_variance(
|
value_fn=ignore_variance(
|
||||||
lambda data: (
|
lambda data: (utcnow() - timedelta(seconds=data["time_printing"])),
|
||||||
utcnow() - timedelta(seconds=data["progress"]["printTime"])
|
|
||||||
),
|
|
||||||
timedelta(minutes=2),
|
timedelta(minutes=2),
|
||||||
),
|
),
|
||||||
available_fn=lambda data: data.get("progress") is not None,
|
available_fn=lambda data: data.get("time_printing") is not None,
|
||||||
),
|
),
|
||||||
PrusaLinkSensorEntityDescription[JobInfo](
|
PrusaLinkSensorEntityDescription[JobInfo](
|
||||||
key="job.finish",
|
key="job.finish",
|
||||||
@ -157,12 +174,10 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = {
|
|||||||
icon="mdi:clock-end",
|
icon="mdi:clock-end",
|
||||||
device_class=SensorDeviceClass.TIMESTAMP,
|
device_class=SensorDeviceClass.TIMESTAMP,
|
||||||
value_fn=ignore_variance(
|
value_fn=ignore_variance(
|
||||||
lambda data: (
|
lambda data: (utcnow() + timedelta(seconds=data["time_remaining"])),
|
||||||
utcnow() + timedelta(seconds=data["progress"]["printTimeLeft"])
|
|
||||||
),
|
|
||||||
timedelta(minutes=2),
|
timedelta(minutes=2),
|
||||||
),
|
),
|
||||||
available_fn=lambda data: data.get("progress") is not None,
|
available_fn=lambda data: data.get("time_remaining") is not None,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,8 @@
|
|||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "[%key:common::config_flow::data::host%]",
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -15,15 +16,25 @@
|
|||||||
"not_supported": "Only PrusaLink API v2 is supported"
|
"not_supported": "Only PrusaLink API v2 is supported"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"firmware_5_1_required": {
|
||||||
|
"description": "The PrusaLink integration has been updated to utilize the latest v1 API endpoints, which require firmware version 4.7.0 or later. If you own a Prusa Mini, please make sure your printer is running firmware 5.1.0 or a more recent version, as firmware versions 4.7.x and 5.0.x are not available for this model.\n\nFollow the guide below to update your {entry_title}.\n* [Prusa Mini Firmware Update]({prusa_mini_firmware_update})\n* [Prusa MK4/XL Firmware Update]({prusa_mk4_xl_firmware_update})\n\nAfter you've updated your printer's firmware, make sure to reload the config entry to fix this issue.",
|
||||||
|
"title": "Firmware update required"
|
||||||
|
}
|
||||||
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"printer_state": {
|
"printer_state": {
|
||||||
"state": {
|
"state": {
|
||||||
"cancelling": "Cancelling",
|
|
||||||
"idle": "[%key:common::state::idle%]",
|
"idle": "[%key:common::state::idle%]",
|
||||||
|
"busy": "Busy",
|
||||||
|
"printing": "Printing",
|
||||||
"paused": "[%key:common::state::paused%]",
|
"paused": "[%key:common::state::paused%]",
|
||||||
"pausing": "Pausing",
|
"finished": "Finished",
|
||||||
"printing": "Printing"
|
"stopped": "Stopped",
|
||||||
|
"error": "Error",
|
||||||
|
"attention": "Attention",
|
||||||
|
"ready": "Ready"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heatbed_temperature": {
|
"heatbed_temperature": {
|
||||||
@ -56,6 +67,15 @@
|
|||||||
"print_speed": {
|
"print_speed": {
|
||||||
"name": "Print speed"
|
"name": "Print speed"
|
||||||
},
|
},
|
||||||
|
"print_flow": {
|
||||||
|
"name": "Print flow"
|
||||||
|
},
|
||||||
|
"fan_hotend": {
|
||||||
|
"name": "Hotend fan"
|
||||||
|
},
|
||||||
|
"fan_print": {
|
||||||
|
"name": "Print fan"
|
||||||
|
},
|
||||||
"z_height": {
|
"z_height": {
|
||||||
"name": "Z-Height"
|
"name": "Z-Height"
|
||||||
}
|
}
|
||||||
|
@ -2005,7 +2005,7 @@ pyprof2calltree==1.4.5
|
|||||||
pyprosegur==0.0.9
|
pyprosegur==0.0.9
|
||||||
|
|
||||||
# homeassistant.components.prusalink
|
# homeassistant.components.prusalink
|
||||||
pyprusalink==1.1.0
|
pyprusalink==2.0.0
|
||||||
|
|
||||||
# homeassistant.components.ps4
|
# homeassistant.components.ps4
|
||||||
pyps4-2ndscreen==1.3.1
|
pyps4-2ndscreen==1.3.1
|
||||||
|
@ -1525,7 +1525,7 @@ pyprof2calltree==1.4.5
|
|||||||
pyprosegur==0.0.9
|
pyprosegur==0.0.9
|
||||||
|
|
||||||
# homeassistant.components.prusalink
|
# homeassistant.components.prusalink
|
||||||
pyprusalink==1.1.0
|
pyprusalink==2.0.0
|
||||||
|
|
||||||
# homeassistant.components.ps4
|
# homeassistant.components.ps4
|
||||||
pyps4-2ndscreen==1.3.1
|
pyps4-2ndscreen==1.3.1
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
"""Fixtures for PrusaLink."""
|
"""Fixtures for PrusaLink."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.prusalink import DOMAIN
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
@ -11,7 +12,9 @@ from tests.common import MockConfigEntry
|
|||||||
def mock_config_entry(hass):
|
def mock_config_entry(hass):
|
||||||
"""Mock a PrusaLink config entry."""
|
"""Mock a PrusaLink config entry."""
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain="prusalink", data={"host": "http://example.com", "api_key": "abcdefgh"}
|
domain=DOMAIN,
|
||||||
|
data={"host": "http://example.com", "username": "dummy", "password": "dummypw"},
|
||||||
|
version=2,
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
return entry
|
return entry
|
||||||
@ -23,96 +26,138 @@ def mock_version_api(hass):
|
|||||||
resp = {
|
resp = {
|
||||||
"api": "2.0.0",
|
"api": "2.0.0",
|
||||||
"server": "2.1.2",
|
"server": "2.1.2",
|
||||||
"text": "PrusaLink MINI",
|
"text": "PrusaLink",
|
||||||
"hostname": "PrusaMINI",
|
"hostname": "PrusaXL",
|
||||||
}
|
}
|
||||||
with patch("pyprusalink.PrusaLink.get_version", return_value=resp):
|
with patch("pyprusalink.PrusaLink.get_version", return_value=resp):
|
||||||
yield resp
|
yield resp
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_printer_api(hass):
|
def mock_info_api(hass):
|
||||||
|
"""Mock PrusaLink info API."""
|
||||||
|
resp = {
|
||||||
|
"nozzle_diameter": 0.40,
|
||||||
|
"mmu": False,
|
||||||
|
"serial": "serial-1337",
|
||||||
|
"hostname": "PrusaXL",
|
||||||
|
"min_extrusion_temp": 170,
|
||||||
|
}
|
||||||
|
with patch("pyprusalink.PrusaLink.get_info", return_value=resp):
|
||||||
|
yield resp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_get_legacy_printer(hass):
|
||||||
|
"""Mock PrusaLink printer API."""
|
||||||
|
resp = {"telemetry": {"material": "PLA"}}
|
||||||
|
with patch("pyprusalink.PrusaLink.get_legacy_printer", return_value=resp):
|
||||||
|
yield resp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_get_status_idle(hass):
|
||||||
"""Mock PrusaLink printer API."""
|
"""Mock PrusaLink printer API."""
|
||||||
resp = {
|
resp = {
|
||||||
"telemetry": {
|
"storage": {
|
||||||
"temp-bed": 41.9,
|
"path": "/usb/",
|
||||||
"temp-nozzle": 47.8,
|
"name": "usb",
|
||||||
"print-speed": 100,
|
"read_only": False,
|
||||||
"z-height": 1.8,
|
|
||||||
"material": "PLA",
|
|
||||||
},
|
},
|
||||||
"temperature": {
|
"printer": {
|
||||||
"tool0": {"actual": 47.8, "target": 210.1, "display": 0.0, "offset": 0},
|
"state": "IDLE",
|
||||||
"bed": {"actual": 41.9, "target": 60.5, "offset": 0},
|
"temp_bed": 41.9,
|
||||||
},
|
"target_bed": 60.5,
|
||||||
"state": {
|
"temp_nozzle": 47.8,
|
||||||
"text": "Operational",
|
"target_nozzle": 210.1,
|
||||||
"flags": {
|
"axis_z": 1.8,
|
||||||
"operational": True,
|
"axis_x": 7.9,
|
||||||
"paused": False,
|
"axis_y": 8.4,
|
||||||
"printing": False,
|
"flow": 100,
|
||||||
"cancelling": False,
|
"speed": 100,
|
||||||
"pausing": False,
|
"fan_hotend": 100,
|
||||||
"sdReady": False,
|
"fan_print": 75,
|
||||||
"error": False,
|
|
||||||
"closedOnError": False,
|
|
||||||
"ready": True,
|
|
||||||
"busy": False,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
with patch("pyprusalink.PrusaLink.get_printer", return_value=resp):
|
with patch("pyprusalink.PrusaLink.get_status", return_value=resp):
|
||||||
|
yield resp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_get_status_printing(hass):
|
||||||
|
"""Mock PrusaLink printer API."""
|
||||||
|
resp = {
|
||||||
|
"job": {
|
||||||
|
"id": 129,
|
||||||
|
"progress": 37.00,
|
||||||
|
"time_remaining": 73020,
|
||||||
|
"time_printing": 43987,
|
||||||
|
},
|
||||||
|
"storage": {"path": "/usb/", "name": "usb", "read_only": False},
|
||||||
|
"printer": {
|
||||||
|
"state": "PRINTING",
|
||||||
|
"temp_bed": 53.9,
|
||||||
|
"target_bed": 85.0,
|
||||||
|
"temp_nozzle": 6.0,
|
||||||
|
"target_nozzle": 0.0,
|
||||||
|
"axis_z": 5.0,
|
||||||
|
"flow": 100,
|
||||||
|
"speed": 100,
|
||||||
|
"fan_hotend": 5000,
|
||||||
|
"fan_print": 2500,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with patch("pyprusalink.PrusaLink.get_status", return_value=resp):
|
||||||
yield resp
|
yield resp
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_job_api_idle(hass):
|
def mock_job_api_idle(hass):
|
||||||
"""Mock PrusaLink job API having no job."""
|
"""Mock PrusaLink job API having no job."""
|
||||||
|
resp = {}
|
||||||
|
with patch("pyprusalink.PrusaLink.get_job", return_value=resp):
|
||||||
|
yield resp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_job_api_printing(hass):
|
||||||
|
"""Mock PrusaLink printing."""
|
||||||
resp = {
|
resp = {
|
||||||
"state": "Operational",
|
"id": 129,
|
||||||
"job": None,
|
"state": "PRINTING",
|
||||||
"progress": None,
|
"progress": 37.00,
|
||||||
|
"time_remaining": 73020,
|
||||||
|
"time_printing": 43987,
|
||||||
|
"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):
|
with patch("pyprusalink.PrusaLink.get_job", return_value=resp):
|
||||||
yield resp
|
yield resp
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_job_api_printing(hass, mock_printer_api, mock_job_api_idle):
|
def mock_job_api_paused(hass, mock_get_status_printing, mock_job_api_printing):
|
||||||
"""Mock PrusaLink printing."""
|
|
||||||
mock_printer_api["state"]["text"] = "Printing"
|
|
||||||
mock_printer_api["state"]["flags"]["printing"] = True
|
|
||||||
|
|
||||||
mock_job_api_idle.update(
|
|
||||||
{
|
|
||||||
"state": "Printing",
|
|
||||||
"job": {
|
|
||||||
"estimatedPrintTime": 117007,
|
|
||||||
"file": {
|
|
||||||
"name": "TabletStand3.gcode",
|
|
||||||
"path": "/usb/TABLET~1.GCO",
|
|
||||||
"display": "TabletStand3.gcode",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"progress": {
|
|
||||||
"completion": 0.37,
|
|
||||||
"printTime": 43987,
|
|
||||||
"printTimeLeft": 73020,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_job_api_paused(hass, mock_printer_api, mock_job_api_idle):
|
|
||||||
"""Mock PrusaLink paused printing."""
|
"""Mock PrusaLink paused printing."""
|
||||||
mock_printer_api["state"]["text"] = "Paused"
|
mock_job_api_printing["state"] = "PAUSED"
|
||||||
mock_printer_api["state"]["flags"]["printing"] = False
|
mock_get_status_printing["printer"]["state"] = "PAUSED"
|
||||||
mock_printer_api["state"]["flags"]["paused"] = True
|
|
||||||
|
|
||||||
mock_job_api_idle["state"] = "Paused"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_api(mock_version_api, mock_printer_api, mock_job_api_idle):
|
def mock_api(
|
||||||
|
mock_version_api,
|
||||||
|
mock_info_api,
|
||||||
|
mock_get_legacy_printer,
|
||||||
|
mock_get_status_idle,
|
||||||
|
mock_job_api_idle,
|
||||||
|
):
|
||||||
"""Mock PrusaLink API."""
|
"""Mock PrusaLink API."""
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
"""Test Prusalink buttons."""
|
"""Test Prusalink buttons."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from pyprusalink import Conflict
|
from pyprusalink.types import Conflict
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
@ -32,6 +32,7 @@ async def test_button_pause_cancel(
|
|||||||
mock_api,
|
mock_api,
|
||||||
hass_client: ClientSessionGenerator,
|
hass_client: ClientSessionGenerator,
|
||||||
mock_job_api_printing,
|
mock_job_api_printing,
|
||||||
|
mock_get_status_printing,
|
||||||
object_id,
|
object_id,
|
||||||
method,
|
method,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -66,9 +67,12 @@ async def test_button_pause_cancel(
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("object_id", "method"),
|
("object_id", "method"),
|
||||||
(("mock_title_resume_job", "resume_job"),),
|
(
|
||||||
|
("mock_title_cancel_job", "cancel_job"),
|
||||||
|
("mock_title_resume_job", "resume_job"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
async def test_button_resume(
|
async def test_button_resume_cancel(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry,
|
mock_config_entry,
|
||||||
mock_api,
|
mock_api,
|
||||||
|
@ -49,13 +49,13 @@ async def test_camera_active_job(
|
|||||||
|
|
||||||
client = await hass_client()
|
client = await hass_client()
|
||||||
|
|
||||||
with patch("pyprusalink.PrusaLink.get_large_thumbnail", return_value=b"hello"):
|
with patch("pyprusalink.PrusaLink.get_file", return_value=b"hello"):
|
||||||
resp = await client.get("/api/camera_proxy/camera.mock_title_preview")
|
resp = await client.get("/api/camera_proxy/camera.mock_title_preview")
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
assert await resp.read() == b"hello"
|
assert await resp.read() == b"hello"
|
||||||
|
|
||||||
# Make sure we hit cached value.
|
# Make sure we hit cached value.
|
||||||
with patch("pyprusalink.PrusaLink.get_large_thumbnail", side_effect=ValueError):
|
with patch("pyprusalink.PrusaLink.get_file", side_effect=ValueError):
|
||||||
resp = await client.get("/api/camera_proxy/camera.mock_title_preview")
|
resp = await client.get("/api/camera_proxy/camera.mock_title_preview")
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
assert await resp.read() == b"hello"
|
assert await resp.read() == b"hello"
|
||||||
|
@ -25,16 +25,18 @@ async def test_form(hass: HomeAssistant, mock_version_api) -> None:
|
|||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{
|
{
|
||||||
"host": "http://1.1.1.1/",
|
"host": "http://1.1.1.1/",
|
||||||
"api_key": "abcdefg",
|
"username": "abcdefg",
|
||||||
|
"password": "abcdefg",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert result2["type"] == FlowResultType.CREATE_ENTRY
|
assert result2["type"] == FlowResultType.CREATE_ENTRY
|
||||||
assert result2["title"] == "PrusaMINI"
|
assert result2["title"] == "PrusaXL"
|
||||||
assert result2["data"] == {
|
assert result2["data"] == {
|
||||||
"host": "http://1.1.1.1",
|
"host": "http://1.1.1.1",
|
||||||
"api_key": "abcdefg",
|
"username": "abcdefg",
|
||||||
|
"password": "abcdefg",
|
||||||
}
|
}
|
||||||
assert len(mock_setup_entry.mock_calls) == 1
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
@ -53,7 +55,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
|
|||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{
|
{
|
||||||
"host": "1.1.1.1",
|
"host": "1.1.1.1",
|
||||||
"api_key": "abcdefg",
|
"username": "abcdefg",
|
||||||
|
"password": "abcdefg",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -75,7 +78,8 @@ async def test_form_unknown(hass: HomeAssistant) -> None:
|
|||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{
|
{
|
||||||
"host": "1.1.1.1",
|
"host": "1.1.1.1",
|
||||||
"api_key": "abcdefg",
|
"username": "abcdefg",
|
||||||
|
"password": "abcdefg",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -95,7 +99,8 @@ async def test_form_too_low_version(hass: HomeAssistant, mock_version_api) -> No
|
|||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{
|
{
|
||||||
"host": "1.1.1.1",
|
"host": "1.1.1.1",
|
||||||
"api_key": "abcdefg",
|
"username": "abcdefg",
|
||||||
|
"password": "abcdefg",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -115,7 +120,8 @@ async def test_form_invalid_version_2(hass: HomeAssistant, mock_version_api) ->
|
|||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{
|
{
|
||||||
"host": "1.1.1.1",
|
"host": "1.1.1.1",
|
||||||
"api_key": "abcdefg",
|
"username": "abcdefg",
|
||||||
|
"password": "abcdefg",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -137,7 +143,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
|
|||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{
|
{
|
||||||
"host": "1.1.1.1",
|
"host": "1.1.1.1",
|
||||||
"api_key": "abcdefg",
|
"username": "abcdefg",
|
||||||
|
"password": "abcdefg",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -2,14 +2,17 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from pyprusalink import InvalidAuth, PrusaLinkError
|
from pyprusalink.types import InvalidAuth, PrusaLinkError
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.prusalink import DOMAIN
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||||
|
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util.dt import utcnow
|
||||||
|
|
||||||
from tests.common import async_fire_time_changed
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||||
|
|
||||||
|
|
||||||
async def test_unloading(
|
async def test_unloading(
|
||||||
@ -39,7 +42,13 @@ async def test_failed_update(
|
|||||||
assert mock_config_entry.state == ConfigEntryState.LOADED
|
assert mock_config_entry.state == ConfigEntryState.LOADED
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.prusalink.PrusaLink.get_printer",
|
"homeassistant.components.prusalink.PrusaLink.get_version",
|
||||||
|
side_effect=exception,
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.prusalink.PrusaLink.get_status",
|
||||||
|
side_effect=exception,
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.prusalink.PrusaLink.get_legacy_printer",
|
||||||
side_effect=exception,
|
side_effect=exception,
|
||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.prusalink.PrusaLink.get_job",
|
"homeassistant.components.prusalink.PrusaLink.get_job",
|
||||||
@ -50,3 +59,67 @@ async def test_failed_update(
|
|||||||
|
|
||||||
for state in hass.states.async_all():
|
for state in hass.states.async_all():
|
||||||
assert state.state == "unavailable"
|
assert state.state == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migration_1_2(
|
||||||
|
hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api
|
||||||
|
) -> None:
|
||||||
|
"""Test migrating from version 1 to 2."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
CONF_HOST: "http://prusaxl.local",
|
||||||
|
CONF_API_KEY: "api-key",
|
||||||
|
},
|
||||||
|
version=1,
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
config_entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
|
||||||
|
# Ensure that we have username, password after migration
|
||||||
|
assert len(config_entries) == 1
|
||||||
|
assert config_entries[0].data == {
|
||||||
|
CONF_HOST: "http://prusaxl.local",
|
||||||
|
CONF_USERNAME: "maker",
|
||||||
|
CONF_PASSWORD: "api-key",
|
||||||
|
}
|
||||||
|
# Make sure that we don't have any issues
|
||||||
|
assert len(issue_registry.issues) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outdated_firmware_migration_1_2(
|
||||||
|
hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api
|
||||||
|
) -> None:
|
||||||
|
"""Test migrating from version 1 to 2."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
CONF_HOST: "http://prusaxl.local",
|
||||||
|
CONF_API_KEY: "api-key",
|
||||||
|
},
|
||||||
|
version=1,
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"pyprusalink.PrusaLink.get_info",
|
||||||
|
side_effect=InvalidAuth,
|
||||||
|
):
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert entry.state == ConfigEntryState.SETUP_ERROR
|
||||||
|
# Make sure that we don't have thrown the issues
|
||||||
|
assert len(issue_registry.issues) == 1
|
||||||
|
|
||||||
|
# Reloading the integration with a working API (e.g. User updated firmware)
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Integration should be running now, the issue should be gone
|
||||||
|
assert entry.state == ConfigEntryState.LOADED
|
||||||
|
assert len(issue_registry.issues) == 0
|
||||||
|
@ -15,6 +15,7 @@ from homeassistant.const import (
|
|||||||
ATTR_DEVICE_CLASS,
|
ATTR_DEVICE_CLASS,
|
||||||
ATTR_UNIT_OF_MEASUREMENT,
|
ATTR_UNIT_OF_MEASUREMENT,
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
|
REVOLUTIONS_PER_MINUTE,
|
||||||
Platform,
|
Platform,
|
||||||
UnitOfLength,
|
UnitOfLength,
|
||||||
UnitOfTemperature,
|
UnitOfTemperature,
|
||||||
@ -44,11 +45,15 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api)
|
|||||||
assert state.state == "idle"
|
assert state.state == "idle"
|
||||||
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM
|
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM
|
||||||
assert state.attributes[ATTR_OPTIONS] == [
|
assert state.attributes[ATTR_OPTIONS] == [
|
||||||
"cancelling",
|
|
||||||
"idle",
|
"idle",
|
||||||
"paused",
|
"busy",
|
||||||
"pausing",
|
|
||||||
"printing",
|
"printing",
|
||||||
|
"paused",
|
||||||
|
"finished",
|
||||||
|
"stopped",
|
||||||
|
"error",
|
||||||
|
"attention",
|
||||||
|
"ready",
|
||||||
]
|
]
|
||||||
|
|
||||||
state = hass.states.get("sensor.mock_title_heatbed_temperature")
|
state = hass.states.get("sensor.mock_title_heatbed_temperature")
|
||||||
@ -95,6 +100,11 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api)
|
|||||||
assert state is not None
|
assert state is not None
|
||||||
assert state.state == "PLA"
|
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")
|
state = hass.states.get("sensor.mock_title_progress")
|
||||||
assert state is not None
|
assert state is not None
|
||||||
assert state.state == "unavailable"
|
assert state.state == "unavailable"
|
||||||
@ -114,12 +124,22 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api)
|
|||||||
assert state.state == "unavailable"
|
assert state.state == "unavailable"
|
||||||
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP
|
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(
|
async def test_sensors_active_job(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry,
|
mock_config_entry,
|
||||||
mock_api,
|
mock_api,
|
||||||
mock_printer_api,
|
mock_get_status_printing,
|
||||||
mock_job_api_printing,
|
mock_job_api_printing,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test sensors while active job."""
|
"""Test sensors while active job."""
|
||||||
@ -140,7 +160,7 @@ async def test_sensors_active_job(
|
|||||||
|
|
||||||
state = hass.states.get("sensor.mock_title_filename")
|
state = hass.states.get("sensor.mock_title_filename")
|
||||||
assert state is not None
|
assert state is not None
|
||||||
assert state.state == "TabletStand3.gcode"
|
assert state.state == "TabletStand3.bgcode"
|
||||||
|
|
||||||
state = hass.states.get("sensor.mock_title_print_start")
|
state = hass.states.get("sensor.mock_title_print_start")
|
||||||
assert state is not None
|
assert state is not None
|
||||||
@ -151,3 +171,13 @@ async def test_sensors_active_job(
|
|||||||
assert state is not None
|
assert state is not None
|
||||||
assert state.state == "2022-08-28T10:17:00+00:00"
|
assert state.state == "2022-08-28T10:17:00+00:00"
|
||||||
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP
|
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 == "5000"
|
||||||
|
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 == "2500"
|
||||||
|
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE
|
||||||
|
Loading…
x
Reference in New Issue
Block a user