Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | % |
Expand Down
2 changes: 1 addition & 1 deletion custom_components/flashforge/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
117 changes: 101 additions & 16 deletions custom_components/flashforge/sensor.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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<days>\d+)d)?"
r"(?:(?P<hours>\d+)h)?"
r"(?:(?P<minutes>\d+)m)?"
r"(?:(?P<seconds>\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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
Loading