diff --git a/AGENTS.md b/AGENTS.md index e162c9b..4d0d04d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Working agreement for AI assistants contributing to `ff-5mp-hass`. ## Project Snapshot - **Integration:** FlashForge printers for Home Assistant (HTTP API only). -- **Current release:** `v1.3.0` (in-flight; not yet tagged). Last published: `v1.2.0`. +- **Current release:** `v1.3.5` (in-flight; not yet tagged). Last published: `v1.3.4`. - **Supported printers:** `AD5X`, `Adventurer 5M`, `Adventurer 5M Pro`, `Creator 5`, and `Creator 5 Pro` only. - **Entities shipped:** 56 total (38 sensors, 5 binary sensors, 2 switches (the camera switch is not created on the Creator 5 series), 1 select, 4 buttons, 1 camera, 5 images — the g-code thumbnail plus 4 Material Station slot color swatches). - **Key dependency:** `flashforge-python-api>=1.3.4` (see sibling repo `ff-5mp-api-py`). @@ -43,6 +43,7 @@ Working agreement for AI assistants contributing to `ff-5mp-hass`. - **Capability-gated entities must be added when the capability appears, not only at setup.** Platform setup can run before the printer has reported a capability, and the first refresh may fail outright. Add what is available, then watch `coordinator.async_add_listener` for the rest (see `image.py` / `sensor.py`), latching so a capability is only added once and registering the teardown with `entry.async_on_unload`. - **A button's availability must not depend on anything but reachability.** A button entity is stateless — its state *is* the last-press timestamp — so any write of that state is reported to the logbook as a press. Gating availability on a selection or a mode means changing that input logs a phantom press. Validate in `async_press` and raise `ServiceValidationError` instead. - **Never validate the *range* of data received from the printer.** Pydantic validates a model all-or-nothing, so a single `ge=`/`le=` on any one of ~50 `/detail` fields fails the whole response — the library returns "no data" and every entity goes unavailable. Firmware also reports absent hardware with out-of-band sentinels (`chamberTemp: -108` on a Creator 5 with no chamber heater) instead of omitting the field, so "impossible" values are routine. That exact constraint made the integration unusable for a whole printer configuration across three releases (issue #18) while every message blamed the network. Inbound models validate types only; ranges belong on outbound command models, where a bad value is our own bug. Needed bounds get normalized in the parser, never rejected. Same rule for required fields: only require what the payload is meaningless without. +- **A zero firmware ETA is not proof that no estimate exists.** Adventurer 5M firmware can report `estimatedTime: 0` for an entire active print while still reporting elapsed time, fractional progress, and a slicer-generated filename ending in a duration such as `4h13m`. Keep Remaining Time and Print Completion Time on the shared `_remaining_time()` path: prefer a positive firmware ETA, then the filename's slicer total minus elapsed time, then elapsed/progress extrapolation. Only derive fallbacks in an active print state so stale idle fields do not invent an ETA. - **A response we cannot read must never be reported as a printer we cannot reach.** `FlashForgeResponseError` (library ≥1.3.4) means the printer answered and we failed to parse it; a `None` return means the request never got through. The first is a bug report, the second is a network check, and the user can only act on the right one. The config flow maps them to `invalid_response` and `cannot_connect` respectively; `__init__.py` and `coordinator.py` log them with matching, distinct wording. Never flatten the exception into `ConnectionError`. - **Gate on the capability the printer reported, not on the model that usually has it.** Options exist within a model family — the heated chamber is a Creator 5 extra, not a family trait, so chamber sensors gate on `has_chamber_sensor` rather than `is_creator5`. Gating on the family gave chamber-less units two entities pinned at 0 °C. Model identity is only the right signal when the model's API genuinely cannot do the thing at all (filtration, the Creator 5 camera switch). - **"Unavailable" and "not created" are different answers; pick the one that is true.** Grey an entity out (`availability_fn`) when the printer *could* report the feature later — that is a temporary state. Omit it entirely (`supported_fn` in `switch.py`, applied once at setup where model identity is already known) when the model's API cannot perform the action at all. The Creator 5 camera switch is the case: its `streamCtrl_cmd` returns success and does nothing, and `cameraStreamUrl` stays populated so the switch snaps back to `on` — a control that accepts a press, reports success and changes nothing is worse than a missing one. Confirm on hardware before deciding a command is inert; "available but unconfirmed" is a fine interim state, "available and known-inert" is not. diff --git a/CHANGELOG.md b/CHANGELOG.md index e2086a1..2769d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.5] - 2026-07-30 + +### Fixed + +- **Remaining Time and Print Completion Time no longer stay at `0` / `unknown` when Adventurer 5M firmware reports `estimatedTime: 0` throughout an active print.** Both sensors now use one shared estimate with explicit precedence: a positive firmware estimate first; otherwise the slicer duration commonly embedded at the end of the current filename (for example `4h13m`) minus elapsed time; otherwise an elapsed-time/progress extrapolation for renamed files. Idle printers do not derive an estimate from stale job fields. Confirmed read-only on a printing Adventurer 5M whose `/detail` response reported nonzero elapsed time and progress alongside `estimatedTime: 0`. + ## [1.3.4] - 2026-07-26 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index fc2e2a3..a0fe162 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ Guidance for AI coding assistants working in this repository. ## Current State (July 2026) -- Integration **version 1.3.4** (in-flight; 1.3.3 tagged 2026-07-26). +- Integration **version 1.3.5** (in-flight; 1.3.4 tagged 2026-07-26). - Provides a complete Home Assistant experience for FlashForge printers using the **HTTP API only**. - Entities shipped: **56 total** (38 sensors, 5 binary sensors, 2 switches, 4 buttons, 1 select, 1 MJPEG camera, 5 images — the g-code thumbnail plus 4 Material Station slot color swatches). - Diagnostics download supported (`diagnostics.py`), with credentials and identifiers redacted. @@ -363,6 +363,7 @@ pytest tests/unit/test_sensor_value_functions.py -v - **Error handling** – Wrap connection issues in `ConfigEntryNotReady`, `ConnectionError`, or `UpdateFailed` so Home Assistant retries gracefully. - **"Could not read the answer" is not "could not reach the printer"** – The library returns `None` when a request never got through and raises `FlashForgeResponseError` when the printer answered with a payload it could not parse. Keep the two apart all the way to the user: the config flow maps the exception to `invalid_response` (never `cannot_connect`), and `__init__.py` / `coordinator.py` log it with wording that sends the user to the issue tracker rather than to their router. Collapsing them is what made issue #18 take three releases — the printer was reachable and the credentials were correct the entire time, but every message on offer said otherwise. - **Never constrain the *range* of data received from the printer** – This applies to the API library, but the integration is what breaks when it is violated. Pydantic validates a model all-or-nothing, so a `ge=`/`le=` on any one of ~50 `/detail` fields can fail the whole response and take every entity offline. Firmware also signals absent hardware with out-of-band sentinels (`chamberTemp: -108`) rather than by omitting the field, so "impossible" values are normal. Inbound models validate types only; range constraints belong on outbound command models, where a bad value is our own bug. If a new field needs bounds, normalize it in the parser, don't reject it. +- **A zero firmware ETA is not proof that no estimate exists** – Adventurer 5M firmware can report `estimatedTime: 0` for an entire active print while still reporting elapsed time, fractional progress, and a slicer-generated filename ending in a duration such as `4h13m`. Keep Remaining Time and Print Completion Time on the shared `_remaining_time()` path: prefer a positive firmware ETA, then the filename's slicer total minus elapsed time, then elapsed/progress extrapolation. Only derive fallbacks in an active print state so stale idle fields do not invent an ETA. - **Gate capabilities on what the printer reported, not on its model family** – Options exist within a family: the heated chamber is a Creator 5 extra, so chamber entities gate on `has_chamber_sensor`, not `is_creator5`. Model identity is the right signal only for things the model genuinely cannot do at all (filtration, the Creator 5 camera switch). - **Entity additions** - Add to the appropriate entity tuple. diff --git a/README.md b/README.md index 03de699..204856c 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,8 @@ | `sensor.flashforge_current_layer` | Current layer number | - | | `sensor.flashforge_total_layers` | Total layer count | - | | `sensor.flashforge_elapsed_time` | Time spent printing | seconds | -| `sensor.flashforge_remaining_time` | Estimated time remaining | seconds | +| `sensor.flashforge_remaining_time` | Estimated time remaining (printer estimate, slicer filename estimate, or elapsed/progress fallback) | seconds | +| `sensor.flashforge_print_completion_time` | Estimated wall-clock print completion time, using the same estimate as Remaining Time | timestamp | | `sensor.flashforge_filament_length` | Estimated filament length needed | meters | | `sensor.flashforge_filament_weight` | Estimated filament weight | grams | | `sensor.flashforge_print_speed` | Speed adjustment percentage | % | diff --git a/custom_components/flashforge/manifest.json b/custom_components/flashforge/manifest.json index 9c5906b..6eda72d 100644 --- a/custom_components/flashforge/manifest.json +++ b/custom_components/flashforge/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/GhostTypes/ff-5mp-hass/issues", "requirements": ["flashforge-python-api>=1.3.4"], - "version": "1.3.4" + "version": "1.3.5" } diff --git a/custom_components/flashforge/sensor.py b/custom_components/flashforge/sensor.py index 08b1d64..1824a0e 100644 --- a/custom_components/flashforge/sensor.py +++ b/custom_components/flashforge/sensor.py @@ -1,14 +1,13 @@ """Sensor platform for FlashForge integration.""" from __future__ import annotations +import logging +import re from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime -import logging +from datetime import datetime, timedelta from typing import Any -from flashforge.models import FFMachineInfo, MachineState - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -29,9 +28,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity - from homeassistant.util import dt as dt_util +from flashforge.models import FFMachineInfo, MachineState + from .const import DOMAIN from .coordinator import FlashForgeDataUpdateCoordinator from .util import build_device_info @@ -40,8 +40,86 @@ MACHINE_STATE_OPTIONS = [state.value for state in MachineState] +_ACTIVE_PRINT_STATES = ( + MachineState.PRINTING, + MachineState.PAUSED, + MachineState.PAUSING, + MachineState.HEATING, +) + +# Slicers such as OrcaSlicer commonly append their estimate to the generated +# filename (for example ``model_PETG_4h13m.gcode``). Adventurer 5M firmware can +# report ``estimatedTime: 0`` throughout a print even though this suffix, +# elapsed time, and progress are all populated. +_SLICER_DURATION_RE = re.compile( + r"(?:^|[_\s-])(?=\d+[dhms])" + r"(?:(?P\d+)d)?" + r"(?:(?P\d+)h)?" + r"(?:(?P\d+)m)?" + r"(?:(?P\d+)s)?" + r"(?:\.[^.]+)*$", + re.IGNORECASE, +) + + +def _seconds_or_zero(value: object) -> int: + """Normalize a duration-like value to non-negative whole seconds.""" + try: + return max(0, int(float(value or 0))) + except (TypeError, ValueError, OverflowError): + return 0 + + +def _slicer_total_time(file_name: str | None) -> int | None: + """Extract a slicer total-time suffix such as ``4h13m`` from a filename.""" + if not file_name: + return None + match = _SLICER_DURATION_RE.search(file_name) + if match is None: + return None + parts = { + key: int(value) if value is not None else 0 + for key, value in match.groupdict().items() + } + return ( + parts["days"] * 86400 + + parts["hours"] * 3600 + + parts["minutes"] * 60 + + parts["seconds"] + ) + + +def _remaining_time(data: FFMachineInfo) -> int: + """Return the best available remaining-print estimate in seconds. + + Prefer the printer's own remaining-time field when it is usable. Some 5M + firmware reports that field as zero for the whole job, so fall back to the + slicer's total-time suffix minus elapsed time. Files without such a suffix + still get a coarse elapsed/progress extrapolation. + """ + reported_remaining = _seconds_or_zero(getattr(data, "estimated_time", 0)) + if reported_remaining: + return reported_remaining + + if getattr(data, "machine_state", None) not in _ACTIVE_PRINT_STATES: + return 0 + + elapsed = _seconds_or_zero(getattr(data, "print_duration", 0)) + slicer_total = _slicer_total_time(getattr(data, "print_file_name", None)) + if slicer_total is not None and slicer_total > elapsed: + return slicer_total - elapsed + + try: + progress = float(getattr(data, "print_progress", 0) or 0) + except (TypeError, ValueError, OverflowError): + progress = 0 + if elapsed and 0 < progress < 1: + return max(0, round(elapsed * (1 - progress) / progress)) -def _parse_disk_space_mb(raw: str | float | int | None) -> float | None: + return 0 + + +def _parse_disk_space_mb(raw: str | float | None) -> float | None: """Convert the library's pre-formatted disk-space value back into a float (MB).""" if raw is None or raw == "": return None @@ -51,7 +129,9 @@ def _parse_disk_space_mb(raw: str | float | int | None) -> float | None: return None -def _completion_time(data: FFMachineInfo) -> datetime | None: +def _completion_time( + data: FFMachineInfo, *, now: datetime | None = None +) -> datetime | None: """Return the absolute completion timestamp, rounded to the minute. HA 2026 rejects naive datetimes on timestamp sensors, so if the library's @@ -60,16 +140,21 @@ def _completion_time(data: FFMachineInfo) -> datetime | None: Timezone-stamping approach adapted from pcamp96 (GhostTypes/ff-5mp-hass#15). """ - if not data.estimated_time: + remaining = _remaining_time(data) + if not remaining: return None - if data.machine_state not in ( - MachineState.PRINTING, - MachineState.PAUSED, - MachineState.PAUSING, - MachineState.HEATING, - ): + if data.machine_state not in _ACTIVE_PRINT_STATES: return None - ts = data.completion_time + + # The library timestamp is based on the firmware's estimatedTime value. It + # cannot represent our fallbacks when that value is zero, so derive the + # timestamp from the same remaining value exposed by the duration sensor. + ts = ( + data.completion_time + if _seconds_or_zero(getattr(data, "estimated_time", 0)) + else (now or datetime.now(dt_util.DEFAULT_TIME_ZONE)) + + timedelta(seconds=remaining) + ) if ts is None: return None ts = ts.replace(second=0, microsecond=0) @@ -222,7 +307,7 @@ class FlashForgeSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:timer-sand", - value_fn=lambda data: int(data.estimated_time) if data.estimated_time else 0, + value_fn=_remaining_time, ), FlashForgeSensorEntityDescription( key="print_completion_time", diff --git a/tests/unit/test_sensor_value_functions.py b/tests/unit/test_sensor_value_functions.py index 01b6d91..8b95c6e 100644 --- a/tests/unit/test_sensor_value_functions.py +++ b/tests/unit/test_sensor_value_functions.py @@ -17,11 +17,18 @@ # Mock Home Assistant modules before importing integration code from tests.ha_mocks import mock_homeassistant + mock_homeassistant() -from custom_components.flashforge.sensor import SENSORS, _completion_time from flashforge.models import MachineState +from custom_components.flashforge.sensor import ( + SENSORS, + _completion_time, + _remaining_time, + _slicer_total_time, +) + @pytest.mark.unit def test_chamber_sensors_gate_on_the_sensor_not_the_model(): @@ -254,6 +261,63 @@ def test_remaining_time_none(self): sensor = self.get_sensor_by_key("remaining_time") assert sensor.value_fn(self.mock_data) == 0 + @pytest.mark.parametrize( + ("file_name", "expected"), + [ + ("model_PETG_4h13m.gcode", 15180), + ("model_PETG_2m1s.gcode", 121), + ("model_1d2h3m4s.gcode", 93784), + ("model_45s.gcode", 45), + ("model_9mm.gcode", None), + ("model_without_time.gcode", None), + ], + ) + def test_slicer_total_time_from_filename(self, file_name, expected): + """Slicer filename estimates are parsed without mistaking dimensions for time.""" + assert _slicer_total_time(file_name) == expected + + def test_remaining_time_falls_back_to_slicer_filename(self): + """A slicer total in the filename replaces firmware estimatedTime=0.""" + self.mock_data.machine_state = MachineState.PRINTING + self.mock_data.estimated_time = 0 + self.mock_data.print_duration = 3809 + self.mock_data.print_progress = 0.1807149201631546 + self.mock_data.print_file_name = ( + "Gridfinity_Deep_1x1x3_ff_9mm_label_PETG_4h13m.gcode" + ) + + assert _remaining_time(self.mock_data) == 11371 + + def test_remaining_time_prefers_firmware_estimate(self): + """A positive firmware estimate wins over derived fallbacks.""" + self.mock_data.machine_state = MachineState.PRINTING + self.mock_data.estimated_time = 600 + self.mock_data.print_duration = 3809 + self.mock_data.print_progress = 0.1807149201631546 + self.mock_data.print_file_name = "model_PETG_4h13m.gcode" + + assert _remaining_time(self.mock_data) == 600 + + def test_remaining_time_falls_back_to_elapsed_progress(self): + """Files without a slicer suffix still get a coarse ETA.""" + self.mock_data.machine_state = MachineState.PRINTING + self.mock_data.estimated_time = 0 + self.mock_data.print_duration = 3600 + self.mock_data.print_progress = 0.25 + self.mock_data.print_file_name = "renamed.gcode" + + assert _remaining_time(self.mock_data) == 10800 + + def test_remaining_time_fallback_requires_active_print(self): + """Stale filename and progress data do not create an ETA while idle.""" + self.mock_data.machine_state = MachineState.READY + self.mock_data.estimated_time = 0 + self.mock_data.print_duration = 3600 + self.mock_data.print_progress = 0.25 + self.mock_data.print_file_name = "model_PETG_4h13m.gcode" + + assert _remaining_time(self.mock_data) == 0 + def test_filament_length_rounded(self): """Test filament_length sensor rounds to 2 decimals.""" sensor = self.get_sensor_by_key("filament_length") @@ -434,7 +498,9 @@ def test_chamber_temperature_none(self): def test_print_completion_time_adds_timezone_to_naive_datetime(self): """Naive completion_time gets stamped with HA's default timezone (HA 2026 fix).""" self.mock_data.machine_state = MachineState.PRINTING - self.mock_data.completion_time = datetime(2026, 6, 26, 9, 47, 12) # naive + self.mock_data.completion_time = datetime( # noqa: DTZ001 - intentional naive input + 2026, 6, 26, 9, 47, 12 + ) result = _completion_time(self.mock_data) assert result is not None assert result.tzinfo is not None @@ -443,7 +509,9 @@ def test_print_completion_time_adds_timezone_to_naive_datetime(self): def test_print_completion_time_returns_none_when_not_printing(self): """Completion time is None unless actively printing/heating.""" self.mock_data.machine_state = MachineState.READY - self.mock_data.completion_time = datetime(2026, 6, 26, 9, 47, 12) + self.mock_data.completion_time = datetime( # noqa: DTZ001 - intentional naive input + 2026, 6, 26, 9, 47, 12 + ) assert _completion_time(self.mock_data) is None def test_print_completion_time_preserves_aware_datetime(self): @@ -457,6 +525,21 @@ def test_print_completion_time_preserves_aware_datetime(self): assert result is not None assert result.tzinfo is aware.tzinfo + def test_print_completion_time_uses_remaining_time_fallback(self): + """Completion timestamp uses the same fallback as remaining_time.""" + from datetime import timezone + + self.mock_data.machine_state = MachineState.PRINTING + self.mock_data.estimated_time = 0 + self.mock_data.print_duration = 3809 + self.mock_data.print_progress = 0.1807149201631546 + self.mock_data.print_file_name = "model_PETG_4h13m.gcode" + observed_at = datetime(2026, 7, 30, 15, 0, tzinfo=timezone.utc) + + result = _completion_time(self.mock_data, now=observed_at) + + assert result == datetime(2026, 7, 30, 18, 9, tzinfo=timezone.utc) + def test_all_sensors_have_value_fn(self): """Verify all sensors have a value_fn defined.""" for sensor in SENSORS: