Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
8b81d68
prusalink: bump pyprusalink to 3.0.0
heikkih May 13, 2026
c039c08
prusalink: coordinator typing for nullable JobInfo and pre-fetch None
heikkih May 13, 2026
da57938
prusalink: cast undocumented `original` version field for MK3/MK2.5 w…
heikkih May 13, 2026
7c6a8a3
prusalink: skip available_fn when coordinator data is None
heikkih May 13, 2026
a9df938
prusalink tests: mock get_job() returning None for the idle case
heikkih May 13, 2026
e0f7fee
prusalink: address typing surfaced by pyprusalink 3.0.0's py.typed ma…
heikkih May 13, 2026
60d4585
prusalink: replace type:ignore on job sensors with proper narrowing
heikkih May 13, 2026
355cba5
prusalink tests: type mock_api parameter to match idle-job mock yield
heikkih May 13, 2026
d824909
prusalink: switch coordinator TypeVar from constraint to bound
heikkih May 13, 2026
647895a
prusalink: fix mock_job_api_idle Generator type annotation
heikkih May 16, 2026
086b43e
prusalink: use unknown state for job sensors when idle or no job
heikkih May 16, 2026
529432f
prusalink tests: use Generator[None] for idle job fixture
heikkih May 16, 2026
8a5ace6
prusalink: restore variance suppression for job timestamp sensors
heikkih May 16, 2026
b046784
prusalink tests: use Iterator for idle job fixture type
heikkih May 16, 2026
ebdb1d5
prusalink: refactor job-sensor helpers to use _has_active_job predicate
heikkih May 16, 2026
4dda972
prusalink tests: add test for active job with nullable fields
heikkih May 16, 2026
873d3bb
prusalink: add review-driven docs and telemetry regression test
heikkih May 16, 2026
4cd88c5
prusalink: harden mk3 original-version workaround
heikkih May 16, 2026
1533a4f
prusalink: harden legacy material sensor value extraction
heikkih May 16, 2026
7301bad
prusalink tests: use state constants in sensor assertions
heikkih May 16, 2026
29ed1ea
prusalink: deduplicate always-available job sensor callable
heikkih May 16, 2026
2a3e06e
prusalink tests: apply ruff formatting in config flow test
heikkih May 16, 2026
77af260
prusalink: make job timestamp stabilization per-entity
heikkih May 16, 2026
03542e7
prusalink: clarify nullable-field behavior in job helper docstrings
heikkih May 16, 2026
d673297
prusalink: enforce exclusive value_fn and value_fn_factory
heikkih May 16, 2026
e141836
prusalink tests: add two-config-entry timestamp isolation regression
heikkih May 16, 2026
afa3870
prusalink: wrap job helper docstrings
heikkih May 16, 2026
3ea8d6a
Remove unused job timestamp helpers in prusalink
heikkih May 16, 2026
3902167
prusalink: finalize minimal pyprusalink 3.0 bump adjustments
heikkih May 21, 2026
fc380cf
Merge branch 'home-assistant:dev' into chore/prusalink-bump-3.0.0
heikkih May 21, 2026
41a9d7c
Move improved handling of legacyprinter to followup-PR
heikkih May 21, 2026
71a1aad
prusalink: keep bump scope and fix binary_sensor mypy
heikkih May 21, 2026
31ff0ed
prusalink: fix mypy in config_flow and sensor
heikkih May 21, 2026
d0b43a9
prusalink: rollback fixes for previous local typing-errors
heikkih May 21, 2026
90bc2df
prusalink: align typing fixes with CI mypy checks
heikkih May 21, 2026
6848861
prusalink: narrow status_connect typing for mypy with 3.0.0
heikkih May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions homeassistant/components/prusalink/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

from collections.abc import Callable
from dataclasses import dataclass
from typing import cast

from pyprusalink.types import JobInfo, PrinterInfo, PrinterStatus
from pyprusalink.types import JobInfo, PrinterInfo, PrinterStatus, StatusInfo
from pyprusalink.types_legacy import LegacyPrinterStatus

from homeassistant.components.binary_sensor import (
Expand Down Expand Up @@ -35,7 +36,9 @@ class PrusaLinkBinarySensorEntityDescription[
PrusaLinkBinarySensorEntityDescription[PrinterStatus](
key="printer.status_connect",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
value_fn=lambda data: data["printer"]["status_connect"]["ok"],
value_fn=lambda data: cast(
bool, cast(StatusInfo, data["printer"]["status_connect"])["ok"]
),
supported_fn=lambda data: (
data["printer"].get("status_connect") is not None
and data["printer"]["status_connect"].get("ok") is not None
Expand Down
9 changes: 5 additions & 4 deletions homeassistant/components/prusalink/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import asyncio
import logging
from typing import Any
from typing import Any, cast

from awesomeversion import AwesomeVersion, AwesomeVersionException
from httpx import HTTPError, InvalidURL
Expand Down Expand Up @@ -41,9 +41,10 @@ def ensure_printer_is_supported(version: VersionInfo) -> None:

# 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"]):
original = cast(str, version.get("original", ""))
Comment thread
heikkih marked this conversation as resolved.
if original.startswith(("PrusaLink I3MK3", "PrusaLink I3MK2")) and (
AwesomeVersion("0.7.2") <= AwesomeVersion(version["server"])
):
return

except AwesomeVersionException as err:
Expand Down
37 changes: 31 additions & 6 deletions homeassistant/components/prusalink/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,20 @@
# rapidly-changing metrics.
_MINIMUM_REFRESH_INTERVAL = 1.0

T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo, VersionInfo)
# Job is the only coordinator whose payload can be None — pyprusalink's
# get_job() returns None on HTTP 204 when no job is running. The other
# endpoints always return data or raise on failure. Using `bound=` rather
# than constraint members so `JobInfo | None` fits without forcing a union
# into the constraint list.
T = TypeVar(
"T",
bound=PrinterStatus
| LegacyPrinterStatus
| JobInfo
| None
| PrinterInfo
| VersionInfo,
)
Comment thread
heikkih marked this conversation as resolved.


type PrusaLinkConfigEntry = ConfigEntry[dict[str, PrusaLinkUpdateCoordinator]]
Expand Down Expand Up @@ -85,8 +98,15 @@ def expect_change(self) -> None:
"""Expect a change."""
self.expect_change_until = monotonic() + 30

def _get_update_interval(self, data: T) -> timedelta:
"""Get new update interval."""
def _get_update_interval(self, data: T | None) -> timedelta:
"""Get new update interval.

`data` is unused by the base implementation today, but kept on the
signature so subclasses can override based on payload state — e.g. a
future transfer coordinator that polls faster while a transfer is
active. The base class is called once from `__init__` with `None`
before the first fetch, hence `T | None`.
"""
if self.expect_change_until > monotonic():
return timedelta(seconds=5)

Expand All @@ -109,10 +129,15 @@ async def _fetch_data(self) -> LegacyPrinterStatus:
return await self.api.get_legacy_printer()


class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]):
"""Job update coordinator."""
class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo | None]):
Comment thread
heikkih marked this conversation as resolved.
Comment thread
heikkih marked this conversation as resolved.
"""Job update coordinator.

The job endpoint returns nothing (HTTP 204) when no job is running, so
`data` can legitimately be `None` here. Entity code that reads from this
coordinator's data must be `None`-aware.
"""

async def _fetch_data(self) -> JobInfo:
async def _fetch_data(self) -> JobInfo | None:
"""Fetch the printer data."""
return await self.api.get_job()
Comment thread
heikkih marked this conversation as resolved.

Expand Down
11 changes: 9 additions & 2 deletions homeassistant/components/prusalink/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@ class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]):
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.entity_description.available_fn(
self.coordinator.data
# `coordinator.data` can be None when the underlying endpoint
# returns no payload — e.g. the job coordinator yields None when
# no job is running on pyprusalink >= 3.0.0. Short-circuit to
# avoid passing None into `available_fn` lambdas that assume a
# dict (.get(), index, etc.).
return (
super().available
and self.coordinator.data is not None
and self.entity_description.available_fn(self.coordinator.data)
)

@property
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/prusalink/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/prusalink",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyprusalink==2.2.0"]
"requirements": ["pyprusalink==3.0.0"]
}
31 changes: 24 additions & 7 deletions homeassistant/components/prusalink/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@
from datetime import datetime, timedelta
from typing import cast

from pyprusalink.types import JobInfo, PrinterInfo, PrinterState, PrinterStatus
from pyprusalink.types_legacy import LegacyPrinterStatus
from pyprusalink.types import (
JobFilePrint,
JobInfo,
PrinterInfo,
PrinterState,
PrinterStatus,
)
from pyprusalink.types_legacy import LegacyPrinterStatus, LegacyPrinterTelemetry

from homeassistant.components.sensor import (
SensorDeviceClass,
Expand Down Expand Up @@ -47,7 +53,7 @@ class PrusaLinkSensorEntityDescription[
PrusaLinkSensorEntityDescription[PrinterStatus](
key="printer.state",
name=None,
value_fn=lambda data: cast(str, data["printer"]["state"].lower()),
value_fn=lambda data: cast(str, data["printer"]["state"]).lower(),
device_class=SensorDeviceClass.ENUM,
options=[state.value.lower() for state in PrinterState],
translation_key="printer_state",
Expand Down Expand Up @@ -149,7 +155,10 @@ class PrusaLinkSensorEntityDescription[
PrusaLinkSensorEntityDescription[LegacyPrinterStatus](
key="printer.telemetry.material",
translation_key="material",
value_fn=lambda data: cast(str, data["telemetry"]["material"]),
value_fn=lambda data: cast(
str, cast(LegacyPrinterTelemetry, data["telemetry"])["material"]
),
available_fn=lambda data: data.get("telemetry") is not None,
Comment thread
heikkih marked this conversation as resolved.
),
),
"job": (
Expand All @@ -166,7 +175,11 @@ class PrusaLinkSensorEntityDescription[
PrusaLinkSensorEntityDescription[JobInfo](
key="job.filename",
translation_key="filename",
value_fn=lambda data: cast(str, data["file"]["display_name"]),
# `available_fn` guarantees `file` is not None at this point;
# the inner cast narrows the Optional for the index.
value_fn=lambda data: cast(
str, cast(JobFilePrint, data["file"])["display_name"]
),
available_fn=lambda data: (
data.get("file") is not None
and data.get("state") != PrinterState.IDLE.value
Expand All @@ -189,8 +202,12 @@ class PrusaLinkSensorEntityDescription[
key="job.finish",
translation_key="print_finish",
device_class=SensorDeviceClass.TIMESTAMP,
# `available_fn` guarantees `time_remaining` is not None at this
# point; the cast narrows the Optional for `timedelta`.
value_fn=ignore_variance(
lambda data: utcnow() + timedelta(seconds=data["time_remaining"]),
lambda data: (
utcnow() + timedelta(seconds=cast(int, data["time_remaining"]))
),
timedelta(minutes=2),
),
available_fn=lambda data: (
Expand All @@ -213,7 +230,7 @@ class PrusaLinkSensorEntityDescription[
translation_key="min_extrusion_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
value_fn=lambda data: cast(int, data["min_extrusion_temp"]),
value_fn=lambda data: data["min_extrusion_temp"],
supported_fn=lambda data: data.get("min_extrusion_temp") is not None,
entity_registry_enabled_default=False,
),
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 9 additions & 6 deletions tests/components/prusalink/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,14 @@ def mock_get_status_printing() -> Generator[dict[str, Any]]:


@pytest.fixture
def mock_job_api_idle() -> Generator[dict[str, Any]]:
"""Mock PrusaLink job API having no job."""
resp = {}
with patch("pyprusalink.PrusaLink.get_job", return_value=resp):
yield resp
def mock_job_api_idle() -> Generator[None]:
"""Mock PrusaLink job API having no job.

pyprusalink >= 3.0.0 returns `None` from `get_job()` on HTTP 204 when
no job is running, rather than an empty dict as in 2.x.
"""
with patch("pyprusalink.PrusaLink.get_job", return_value=None):
yield None
Comment thread
heikkih marked this conversation as resolved.
Comment on lines +126 to +133
Comment thread
heikkih marked this conversation as resolved.
Comment thread
heikkih marked this conversation as resolved.


@pytest.fixture
Expand Down Expand Up @@ -206,6 +209,6 @@ def mock_api(
mock_info_api: dict[str, Any],
mock_get_legacy_printer: dict[str, Any],
mock_get_status_idle: dict[str, Any],
mock_job_api_idle: dict[str, Any],
mock_job_api_idle: None,
) -> None:
"""Mock PrusaLink API."""