diff --git a/.gitignore b/.gitignore index 3f7a220..980a917 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Thumbs.db *.log homeassistant/* /homeassistant-prod + +# Local codegraph index (tooling artifact) +.codegraph/ diff --git a/AGENTS.md b/AGENTS.md index 6a64601..6b3de92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Working agreement for AI assistants contributing to `ff-5mp-hass`. - **Integration:** FlashForge printers for Home Assistant (HTTP API only). - **Current release:** `v1.3.0` (in-flight; not yet tagged). Last published: `v1.2.0`. - **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, 1 select, 4 buttons, 1 camera, 5 images — the g-code thumbnail plus 4 Material Station slot color swatches). +- **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.0` (see sibling repo `ff-5mp-api-py`). - **Primary references:** `CLAUDE.md` (agent workflow), `AGENTS.md` (this playbook), `README.md` (user docs), `CHANGELOG.md`, `homeassistant/README.md`, `HOME_ASSISTANT_DOCS_COMPANION.md`, and `HACS_PUBLISHER_COMPANION.md`. @@ -39,6 +39,10 @@ Working agreement for AI assistants contributing to `ff-5mp-hass`. - Record any manual testing nuances in pull requests, issues, or release notes for future reference. - **Identify printers by PID, not name.** `config_flow.py` `_is_supported_detail()` checks the firmware-set `pid` on `/detail` against `SUPPORTED_PIDS = {35, 36, 38, 40, 41}` (5M, 5M Pro, AD5X, Creator 5, Creator 5 Pro); the upstream library (≥1.3.0) does the same internally to derive `is_pro` / `is_ad5x` / `is_creator5` / `is_creator5_pro`. The `name` field is user-mutable via the LCD or cloud and must never be substring-matched for model detection (broke in v1.1.8, fixed in v1.1.9 — see issue #13). Adding a new modern PID means updating `SUPPORTED_PIDS` here AND bumping the library dep floor. - **Never trust the `/product` endpoint for capability gating.** It reliably reports filtration control for the Adventurer 5M Pro but misreports it for the Creator 5 Pro (wrong values). TVOC, chamber fan speed, and the filtration select are therefore gated on model identity (`is_pro OR is_creator5_pro`), not on the `/product`-derived `client.filtration_control` flag. Once the API gates capabilities internally we can re-derive from `/product`. +- **A capability that can be "unknown" will be read as "no".** Firmware omits fields that don't apply to a model, so an absent value means "not reported", and any `None`-able flag invites a consumer to collapse the two. `hasMatlStation` is the case that bit us: AD5X-only, absent on a Creator 5 Pro that has four loaded slots, so the Material Station entities never appeared on the models that have a station. Gate on a derived, always-concrete capability — `FFMachineInfo.has_matl_station` (library ≥1.3.2) — never on a raw `/detail` passthrough. The same trap caught `led_control_override`, where the option's unset `False` was read by the library as "force the capability off" and greyed out the LED switch on every printer; tri-state parameters need `None` for "no opinion". +- **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. +- **"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. ## Standard Workflows ### Feature or Bug Fix diff --git a/CHANGELOG.md b/CHANGELOG.md index 0baa382..c35abd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,18 @@ 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). -## [Unreleased] +## [1.3.2] - 2026-07-26 + +### Changed + +- **The camera switch is no longer created on the Creator 5 series.** v1.3.1 made `switch._camera` *available* on those models, but hardware testing on a Creator 5 Pro (firmware 1.9.4) confirmed the API can no longer act on it: `streamCtrl_cmd` with `action: close` answers `{"code": 0, "Success"}` while port 8080 keeps serving live MJPEG frames 22 seconds later, and `cameraStreamUrl` never changes. The switch could not reflect an off state either — `is_on` reads that same always-present URL, so the next poll flipped it straight back to `on`. Rather than ship a control that silently does nothing, the switch is now omitted entirely on the Creator 5 / Creator 5 Pro; the `camera` entity itself is unaffected and continues to stream. Refs [#17](https://github.com/GhostTypes/ff-5mp-hass/issues/17). + +### Fixed + +- **Material Station entities now appear on the Creator 5 series.** The four slot swatches (`image._ifs_slot_1..4`) and the Active Material Station Slot sensor were gated on `FFMachineInfo.has_matl_station`, which was a straight copy of the raw `hasMatlStation` field from `/detail`. A Creator 5 Pro does not report that field at all — verified on real hardware (pid 41, firmware 1.9.4), where it is absent from `/detail` entirely while `matlStationInfo` reports `slotCnt: 4` and four loaded slots. The flag parsed as `None`, the entities were never created, and the v1.3.0 change that moved the gate off `is_ad5x` had no effect on this model. Fixed in the library (`flashforge-python-api` 1.3.2), which now derives the capability from the slot data; the minimum requirement is raised accordingly. +- **Capability-gated entities are no longer decided once at setup.** The Material Station slot images and every `availability_fn`-gated sensor are now also added when their capability first shows up on a later refresh. Platform setup can run before the printer has reported a capability, and the first refresh may fail outright — either case previously left the printer permanently without those entities. + +- **The LED switch is no longer greyed out on every printer.** The "Always show LED switch" option was passed to the library as `led_control_override` using its unset value `False`. That parameter is tri-state — `None` means "no override", `True` forces the capability on, and `False` forces it **off** — so with the option switched off, which is the default, the integration overrode the printer's own correct capability report and pinned `client.led_control` to `False` on every model. The switch stayed unavailable, and the library additionally refused `set_led_on()` / `set_led_off()` internally, which made enabling the override look like the only way to get a working switch: `True` was the only value that got past the veto. The option now sends `None` when off and `True` only when the user asks for it, which is what it was always meant to do. Reported and diagnosed on a Creator 5 Pro, where `/product` correctly reports `lightCtrlState: 1` all along. Refs [#17](https://github.com/GhostTypes/ff-5mp-hass/issues/17). ## [1.3.1] - 2026-07-23 @@ -212,7 +223,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - FlashForge Adventurer 5M Series - FlashForge Adventurer 4 -[Unreleased]: https://github.com/GhostTypes/ff-5mp-hass/compare/v1.3.1...HEAD +[Unreleased]: https://github.com/GhostTypes/ff-5mp-hass/compare/v1.3.2...HEAD +[1.3.2]: https://github.com/GhostTypes/ff-5mp-hass/compare/v1.3.1...v1.3.2 [1.3.1]: https://github.com/GhostTypes/ff-5mp-hass/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/GhostTypes/ff-5mp-hass/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/GhostTypes/ff-5mp-hass/compare/v1.1.9...v1.2.0 diff --git a/CLAUDE.md b/CLAUDE.md index 27a4c2a..ad8d7ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,8 +2,8 @@ Guidance for AI coding assistants working in this repository. -## Current State (May 2026) -- Integration **version 1.3.0** (in-flight; not yet tagged). +## Current State (July 2026) +- Integration **version 1.3.2** (in-flight; 1.3.1 tagged 2026-07-23). - 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. @@ -58,7 +58,7 @@ Treat this file as the living source of truth for workflows and expectations—u - Entities grouped under a single device with manufacturer/model metadata. - **Control** - LED switch with capability detection (graceful "unavailable" for unsupported models, with an option to override the check). - - Filtration as a `select` entity with Off / Internal / External states (AD5X only). + - Filtration as a `select` entity with Off / Internal / External states (Adventurer 5M Pro / Creator 5 Pro only — gated on `is_pro OR is_creator5_pro`). - Pause / resume / cancel / clear-status buttons with post-action refresh. - MJPEG camera entity targeting `http://:8080/?action=stream`. - **Architecture** @@ -77,10 +77,10 @@ Treat this file as the living source of truth for workflows and expectations—u - `__init__.py` – Config entry setup, HTTP client initialization, coordinator registration, teardown. - `config_flow.py` – Discovery + manual onboarding, reauth + reconfigure flows, credential validation via HTTP, options flow (scan interval + LED-availability override). Enforces `SUPPORTED_PIDS` early via `_is_supported_detail()`. - `coordinator.py` – `DataUpdateCoordinator` wrapping `FlashForgeClient.info.get()` with graceful error handling and cleanup. -- `sensor.py` – 28 sensor entities (operational + diagnostic). Modify the `SENSORS` tuple, translations, and docs together when changing sensors. -- `binary_sensor.py` – 4 machine-state binary sensors (printing, online, error, paused). -- `switch.py` – LED switch with client capability check (capability check can be overridden via options). -- `select.py` – Filtration mode select (Off / Internal / External, AD5X only). +- `sensor.py` – 38 sensor entities (operational + diagnostic). `SENSORS` is composed of `_BASE_SENSORS + TOOLHEAD_SENSORS + CHAMBER_SENSORS` (per-toolhead and heated-chamber sensors are gated on the Creator 5 series). Modify the tuples, translations, and docs together when changing sensors. +- `binary_sensor.py` – 5 machine-state binary sensors (printing, online, error, paused, door-open). `door_open` is availability-gated on `has_door_sensor` (Creator 5 Pro only). +- `switch.py` – LED switch with client capability check (capability check can be overridden via options) and the camera switch. Descriptions carry both an `availability_fn` (greys the entity out; use when the printer may report the feature later) and a `supported_fn` (skips creating it entirely; use when the model's API cannot perform the action at all — the Creator 5 camera switch is inert, so it is never created there). +- `select.py` – Filtration mode select (Off / Internal / External; availability gated on `is_pro OR is_creator5_pro`, i.e. Adventurer 5M Pro / Creator 5 Pro). - `button.py` – Pause / resume / cancel / clear-status commands; request a refresh after each action. - `camera.py` – MJPEG camera entity (`http://:8080/?action=stream` by default). - `image.py` – Hosts the active-print g-code thumbnail entity AND the 4 Material Station slot swatch entities (AD5X / Creator 5 series). Swatches are PNG-encoded by `render_swatch_bytes()` (Pillow) inside an executor; both entity types cache rendered bytes and only invalidate on input change. @@ -237,7 +237,7 @@ The local Home Assistant instance runs in **WSL2 only** with the following setup 1. **Implementation** - Keep everything async; no blocking calls inside Home Assistant callbacks. - Use HTTP-facing client methods (`client.info`, `client.control`, `client.job_control`, etc.). - - Respect capability flags (`client.led_control`, `client.filtration_control`) before exposing features. + - Respect capability flags (`client.led_control` for the LED switch) before exposing features. Do NOT trust the `/product`-derived `client.filtration_control` — gate filtration/TVOC/chamber-fan on model identity (`is_pro OR is_creator5_pro`) instead. 2. **Localization & Docs** - Update `strings.json` and `translations/en.json` whenever UI text changes. - Reflect behavior changes in `README.md`, `CHANGELOG.md`, `CLAUDE.md`, and `AGENTS.md` as appropriate. @@ -282,7 +282,7 @@ pytest tests/unit/ --cov=custom_components.flashforge --cov-report=term-missing pytest tests/unit/test_sensor_value_functions.py -v ``` -**Current coverage (103 tests total):** +**Current coverage (125 tests total):** - `tests/unit/test_discovery.py` – printer discovery protocol - `tests/unit/test_sensor_value_functions.py` – sensor value extraction - `tests/unit/test_binary_sensor_value_functions.py` – binary sensor logic @@ -338,13 +338,13 @@ pytest tests/unit/test_sensor_value_functions.py -v 3. Add the integration via UI; test both discovery and manual paths. 4. Open the created device and verify entities: - Sensors: machine status, nozzle temps/targets, bed temps/targets, progress, file, current/total layers, elapsed/remaining time, filament length/weight, print speed, z offset, nozzle size, filament type, lifetime stats, plus diagnostic sensors (firmware version, free disk space, error code). - - Binary sensors: printing, online, error, paused. + - Binary sensors: printing, online, error, paused, door-open (Creator 5 Pro only). - Switch: LED (may show unavailable on unsupported models unless override is enabled). - - Select: filtration mode — Off / Internal / External (AD5X only). + - Select: filtration mode — Off / Internal / External (Adventurer 5M Pro / Creator 5 Pro only). - Buttons: pause, resume, cancel, clear status. - Camera: MJPEG feed reachable. - Image: g-code thumbnail of the active print. - - Image (AD5X only): four IFS slot swatches (`image.*_ifs_slot_1..4`) showing material color + label, "EMPTY" tile for unloaded slots. + - Image (AD5X / Creator 5 series): four Material Station slot swatches (`image.*_ifs_slot_1..4`) showing material color + label, "EMPTY" tile for unloaded slots. 5. Trigger control actions (pause/resume/cancel, switches) and ensure states refresh. 6. Observe coordinator error handling by temporarily disconnecting the printer and confirming entities surface availability correctly. @@ -356,16 +356,16 @@ pytest tests/unit/test_sensor_value_functions.py -v ## Implementation Guard Rails - **HTTP-first policy** – Do not introduce direct TCP/G-code communication here. If unavoidable, extend the API library (`ff-5mp-api-py`) and consume it via HTTP-style helpers. - **Coordinator as source of truth** – Entities derive state from the coordinator’s latest `FFMachineInfo`. Avoid storing custom copies of printer state in entities. -- **PID for model identity, never the printer name** – Modern HTTP printers report a stable firmware-set integer `pid` on `/detail` (35 = Adventurer 5M, 36 = 5M Pro, 38 = AD5X). The integration enforces this in TWO places that should both stay in sync: - - `config_flow.py` `_is_supported_detail()` reads the raw `/detail` payload during pairing and rejects PIDs not in `SUPPORTED_PIDS = {35, 36, 38}`. This is the early gate — runs before any `FFMachineInfo` parsing happens. - - The library (`flashforge-python-api>=1.2.3`) populates `FFMachineInfo.is_pro` / `is_ad5x` / `pid` from the same value. This is the runtime gate — used by `switch.py` for LED / filtration availability. +- **PID for model identity, never the printer name** – Modern HTTP printers report a stable firmware-set integer `pid` on `/detail` (35 = Adventurer 5M, 36 = 5M Pro, 38 = AD5X, 40 = Creator 5, 41 = Creator 5 Pro). The integration enforces this in TWO places that should both stay in sync: + - `config_flow.py` `_is_supported_detail()` reads the raw `/detail` payload during pairing and rejects PIDs not in `SUPPORTED_PIDS = {35, 36, 38, 40, 41}`. This is the early gate — runs before any `FFMachineInfo` parsing happens. + - The library (`flashforge-python-api>=1.3.0`) populates `FFMachineInfo.is_pro` / `is_ad5x` / `is_creator5` / `is_creator5_pro` / `pid` from the same value. This is the runtime gate — used by `switch.py` for LED availability and by `sensor.py` / `select.py` for model-identity capability gating. - Both gates are needed: the config-flow gate stops unsupported hardware from being added at all; the runtime gate keeps capability flags accurate after pairing. Do NOT substring-match `info.name` — it's user-mutable and broke detection in v1.1.8 (see issue #13 / v1.1.9 fix). When new modern PIDs ship, update `SUPPORTED_PIDS` here AND coordinate a library bump. - **Error handling** – Wrap connection issues in `ConfigEntryNotReady`, `ConnectionError`, or `UpdateFailed` so Home Assistant retries gracefully. - **Entity additions** - Add to the appropriate entity tuple. - Provide unique `key`, icon, units, and defensive `value_fn`. - Update documentation (README, CHANGELOG, CLAUDE/AGENTS) and translations. -- **Options flow** – Currently only the scan interval. Extend cautiously to avoid breaking existing entries. +- **Options flow** – Currently exposes the scan interval and the LED-availability override. Extend cautiously to avoid breaking existing entries. ## Release & Publishing Checklist 1. Implement and document changes. diff --git a/custom_components/flashforge/__init__.py b/custom_components/flashforge/__init__.py index 09a6be5..1fd08d8 100644 --- a/custom_components/flashforge/__init__.py +++ b/custom_components/flashforge/__init__.py @@ -46,13 +46,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) override_led_availability = entry.options.get(CONF_OVERRIDE_LED_AVAILABILITY, False) - # Create FlashForge client + # Create FlashForge client. + # + # `led_control_override` is tri-state, not a boolean: None means "no + # override, trust /product", True forces the capability on, and False forces + # it OFF. Passing the option straight through sent False whenever the user + # had not enabled it - the default - which vetoed the printer's own correct + # capability report and greyed out the LED switch on every model. Only ever + # force the capability when the user actually asked for it. client = FlashForgeClient( ip_address=ip_address, serial_number=serial_number, check_code=check_code, options=FiveMClientConnectionOptions( - led_control_override=override_led_availability, + led_control_override=True if override_led_availability else None, ), ) diff --git a/custom_components/flashforge/image.py b/custom_components/flashforge/image.py index 72f1a89..46ed5fb 100644 --- a/custom_components/flashforge/image.py +++ b/custom_components/flashforge/image.py @@ -10,7 +10,7 @@ from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +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 @@ -45,17 +45,34 @@ async def async_setup_entry( ] printer_name: str = hass.data[DOMAIN][entry.entry_id]["name"] - entities: list[ImageEntity] = [ - FlashForgeThumbnailImage(hass, coordinator, printer_name, entry.entry_id) - ] - if coordinator.data is not None and getattr(coordinator.data, "has_matl_station", False): - entities.extend( + async_add_entities( + [FlashForgeThumbnailImage(hass, coordinator, printer_name, entry.entry_id)] + ) + + slots_added = False + + @callback + def _async_add_slot_images() -> None: + """Add the slot swatches once the printer reports a Material Station. + + Deciding this once at setup strands the entities permanently: platform + setup can run before the station has reported in, and the first refresh + may have failed outright. Keep watching instead. + """ + nonlocal slots_added + if slots_added or coordinator.data is None or not coordinator.data.has_matl_station: + return + slots_added = True + async_add_entities( FlashForgeMaterialStationSlotImage( hass, coordinator, printer_name, entry.entry_id, slot_id ) for slot_id in range(1, IFS_SLOT_COUNT + 1) ) - async_add_entities(entities) + + _async_add_slot_images() + if not slots_added: + entry.async_on_unload(coordinator.async_add_listener(_async_add_slot_images)) # --------------------------------------------------------------------------- # @@ -246,7 +263,7 @@ def __init__( def _slot(self) -> Any | None: data = self.coordinator.data - if data is None or not getattr(data, "has_matl_station", False): + if data is None or not data.has_matl_station: return None station = getattr(data, "matl_station_info", None) if station is None: @@ -274,7 +291,7 @@ def available(self) -> bool: if not self.coordinator.last_update_success: return False data = self.coordinator.data - return bool(data and getattr(data, "has_matl_station", False)) + return bool(data and data.has_matl_station) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/custom_components/flashforge/manifest.json b/custom_components/flashforge/manifest.json index fced6f0..0608ca6 100644 --- a/custom_components/flashforge/manifest.json +++ b/custom_components/flashforge/manifest.json @@ -9,6 +9,6 @@ "integration_type": "device", "iot_class": "local_polling", "issue_tracker": "https://github.com/GhostTypes/ff-5mp-hass/issues", - "requirements": ["flashforge-python-api>=1.3.0"], - "version": "1.3.1" + "requirements": ["flashforge-python-api>=1.3.2"], + "version": "1.3.2" } diff --git a/custom_components/flashforge/sensor.py b/custom_components/flashforge/sensor.py index edcaf7e..68fcc80 100644 --- a/custom_components/flashforge/sensor.py +++ b/custom_components/flashforge/sensor.py @@ -26,7 +26,7 @@ UnitOfTemperature, UnitOfTime, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -80,7 +80,7 @@ def _completion_time(data: FFMachineInfo) -> datetime | None: def _active_ifs_slot(data: FFMachineInfo) -> int | None: """Return the active Material Station slot (1-4), 0 when idle, None when absent.""" - if not getattr(data, "has_matl_station", False): + if not data.has_matl_station: return None station = getattr(data, "matl_station_info", None) if station is None: @@ -298,7 +298,7 @@ class FlashForgeSensorEntityDescription(SensorEntityDescription): icon="mdi:tray-full", state_class=SensorStateClass.MEASUREMENT, value_fn=_active_ifs_slot, - availability_fn=lambda data: bool(getattr(data, "has_matl_station", False)), + availability_fn=lambda data: data.has_matl_station, ), FlashForgeSensorEntityDescription( key="lifetime_filament", @@ -418,15 +418,44 @@ async def async_setup_entry( ] printer_name: str = hass.data[DOMAIN][entry.entry_id]["name"] - data = coordinator.data - entities = [ + async_add_entities( FlashForgeSensor(coordinator, description, printer_name, entry.entry_id) for description in SENSORS if description.availability_fn is None - or (data is not None and description.availability_fn(data)) + ) + + pending = [ + description for description in SENSORS if description.availability_fn is not None ] - async_add_entities(entities) + @callback + def _async_add_available_sensors() -> None: + """Add capability-gated sensors as their capability first shows up. + + Deciding this once at setup strands them permanently: platform setup can + run before the printer has reported the capability, and the first refresh + may have failed outright. Keep watching instead. + """ + data = coordinator.data + if data is None: + return + ready = [ + description + for description in pending + if description.availability_fn is not None and description.availability_fn(data) + ] + if not ready: + return + for description in ready: + pending.remove(description) + async_add_entities( + FlashForgeSensor(coordinator, description, printer_name, entry.entry_id) + for description in ready + ) + + _async_add_available_sensors() + if pending: + entry.async_on_unload(coordinator.async_add_listener(_async_add_available_sensors)) class FlashForgeSensor(CoordinatorEntity[FlashForgeDataUpdateCoordinator], SensorEntity): diff --git a/custom_components/flashforge/switch.py b/custom_components/flashforge/switch.py index a00c001..763f630 100644 --- a/custom_components/flashforge/switch.py +++ b/custom_components/flashforge/switch.py @@ -30,6 +30,13 @@ class FlashForgeSwitchEntityDescription(SwitchEntityDescription): turn_on_fn: Callable[[FlashForgeClient], Any] | None = None turn_off_fn: Callable[[FlashForgeClient], Any] | None = None availability_fn: Callable[[FlashForgeClient], bool] | None = None + # Distinct from availability_fn on purpose. `availability_fn` greys an + # entity out - the right answer when the hardware could report the feature + # later. `supported_fn` decides whether the entity is created at all, which + # is the only honest answer when the model's API cannot perform the action + # and never will. A switch that accepts a press, returns success and changes + # nothing is worse than a missing one. + supported_fn: Callable[[FlashForgeClient], bool] | None = None SWITCHES: tuple[FlashForgeSwitchEntityDescription, ...] = ( @@ -49,8 +56,16 @@ class FlashForgeSwitchEntityDescription(SwitchEntityDescription): is_on_fn=lambda data: bool(getattr(data, "camera_stream_url", "")), turn_on_fn=lambda client: client.control.turn_camera_on(), turn_off_fn=lambda client: client.control.turn_camera_off(), - availability_fn=lambda client: bool( - getattr(client, "is_pro", False) or getattr(client, "is_creator5_pro", False) + availability_fn=lambda client: bool(getattr(client, "is_pro", False)), + # The Creator 5 series dropped stream control from its API. Verified on + # a Creator 5 Pro (firmware 1.9.4): `streamCtrl_cmd` with action=close + # answers {"code": 0, "Success"}, yet port 8080 is still serving live + # MJPEG frames 22s later and `cameraStreamUrl` never changes. The switch + # cannot reflect an off state either - `is_on_fn` reads that same + # always-present URL, so the next poll flips it straight back on. Inert + # and un-toggleable; don't create it. The camera entity is unaffected. + supported_fn=lambda client: not ( + getattr(client, "is_creator5", False) or getattr(client, "is_creator5_pro", False) ), ), ) @@ -71,6 +86,7 @@ async def async_setup_entry( entities = [ FlashForgeSwitch(coordinator, client, description, printer_name, entry.entry_id) for description in SWITCHES + if description.supported_fn is None or description.supported_fn(client) ] async_add_entities(entities) diff --git a/requirements-test.txt b/requirements-test.txt index 585855c..66a113d 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -11,11 +11,16 @@ syrupy>=4.0.0 # API library (install from local path or PyPI) # For local development: uv pip install -e C:\Users\Cope\Documents\GitHub\ff-5mp-api-py -flashforge-python-api>=1.2.3 +flashforge-python-api>=1.3.2 # Network interface detection (required by discovery) netifaces>=0.11.0 +# Swatch rendering in image.py. Home Assistant core ships Pillow, so it stays +# out of the manifest requirements; the test environment has to supply it +# itself or importing the image platform fails at collection. +pillow>=10.0.0 + # NOTE: Integration tests requiring Home Assistant must run in WSL/Linux # Home Assistant and pytest-homeassistant-custom-component are Unix-only # For integration tests: see homeassistant/ directory with WSL setup diff --git a/tests/ha_mocks.py b/tests/ha_mocks.py index 3a979b3..752348f 100644 --- a/tests/ha_mocks.py +++ b/tests/ha_mocks.py @@ -97,6 +97,15 @@ class SwitchEntity(Entity): pass +class ImageEntity(Entity): + """Stub for homeassistant.components.image.ImageEntity.""" + + def __init__(self, hass=None, *args, **kwargs): + # The real ImageEntity takes `hass` positionally and sets up verify_ssl + # / access-token plumbing the tests do not exercise. + self.hass = hass + + class Camera(Entity): """Stub for homeassistant.components.camera.Camera.""" @@ -384,6 +393,10 @@ class OptionsFlow: light_module.ColorMode = ColorMode sys.modules["homeassistant.components.light"] = light_module + image_module = MagicMock() + image_module.ImageEntity = ImageEntity + sys.modules["homeassistant.components.image"] = image_module + camera_module = MagicMock() camera_module.Camera = Camera sys.modules["homeassistant.components.camera"] = camera_module diff --git a/tests/unit/test_capability_gated_entities.py b/tests/unit/test_capability_gated_entities.py new file mode 100644 index 0000000..084a50e --- /dev/null +++ b/tests/unit/test_capability_gated_entities.py @@ -0,0 +1,158 @@ +"""Unit tests for capability-gated entity registration. + +Two failure modes are covered here, both of which left a printer permanently +without entities it should have had: + +1. Gating on a capability the printer reports late (or not on the first poll), + and deciding once at setup. +2. Reading a raw ``/detail`` passthrough as a capability. ``has_matl_station`` + used to mirror the AD5X-only ``hasMatlStation`` field, which the Creator 5 + series never sends - the library derives it from the slot data now, and these + tests pin the integration to that contract. +""" + +import sys +from pathlib import Path +from unittest.mock import Mock + +import pytest + +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from tests.ha_mocks import mock_homeassistant + +mock_homeassistant() + +from custom_components.flashforge.const import DOMAIN +from custom_components.flashforge.image import IFS_SLOT_COUNT +from custom_components.flashforge.image import async_setup_entry as image_setup_entry +from custom_components.flashforge.sensor import async_setup_entry as sensor_setup_entry + + +class _FakeCoordinator: + """Coordinator stub that can publish data after setup, like the real one.""" + + def __init__(self, data=None): + self.data = data + self.last_update_success = True + self.device_model = "Creator 5 Pro" # read by build_device_info + self.serial_number = "SN123456" + self.firmware_version = "1.9.4" + self._listeners = [] + + def async_add_listener(self, listener): + self._listeners.append(listener) + return lambda: self._listeners.remove(listener) + + def publish(self, data) -> None: + """Deliver a refresh to everyone watching.""" + self.data = data + for listener in list(self._listeners): + listener() + + +def _machine_info(*, has_matl_station: bool): + info = Mock() + info.has_matl_station = has_matl_station + station = Mock() + station.slot_infos = [] + info.matl_station_info = station if has_matl_station else None + return info + + +def _setup_args(coordinator): + hass = Mock() + entry = Mock() + entry.entry_id = "entry-1" + entry.async_on_unload = Mock() + hass.data = {DOMAIN: {entry.entry_id: {"coordinator": coordinator, "name": "Printer"}}} + + added = [] + + def async_add_entities(entities): + added.extend(entities) + + return hass, entry, async_add_entities, added + + +def _slot_images(added): + return [e for e in added if "ifs_slot" in (getattr(e, "_attr_unique_id", "") or "")] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_slot_images_added_when_the_station_is_present_at_setup(): + """The common case: the first refresh already showed the station.""" + coordinator = _FakeCoordinator(_machine_info(has_matl_station=True)) + hass, entry, async_add_entities, added = _setup_args(coordinator) + + await image_setup_entry(hass, entry, async_add_entities) + + assert len(_slot_images(added)) == IFS_SLOT_COUNT + # Nothing to wait for, so no listener is registered. + entry.async_on_unload.assert_not_called() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_slot_images_added_when_the_station_reports_in_later(): + """A capability that shows up on a later refresh must still create entities. + + Regression test: platform setup can run before the printer has reported the + station, and the first refresh may have failed outright. Deciding once at + setup left the printer permanently without the swatches. + """ + coordinator = _FakeCoordinator(None) # first refresh failed / not in yet + hass, entry, async_add_entities, added = _setup_args(coordinator) + + await image_setup_entry(hass, entry, async_add_entities) + assert _slot_images(added) == [] + entry.async_on_unload.assert_called_once() # still watching + + coordinator.publish(_machine_info(has_matl_station=True)) + + assert len(_slot_images(added)) == IFS_SLOT_COUNT + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_slot_images_are_added_only_once(): + """Every later refresh must not add a second set of swatches.""" + coordinator = _FakeCoordinator(None) + hass, entry, async_add_entities, added = _setup_args(coordinator) + + await image_setup_entry(hass, entry, async_add_entities) + coordinator.publish(_machine_info(has_matl_station=True)) + coordinator.publish(_machine_info(has_matl_station=True)) + + assert len(_slot_images(added)) == IFS_SLOT_COUNT + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_no_slot_images_without_a_station(): + """A printer that reports no station gets no swatches.""" + coordinator = _FakeCoordinator(_machine_info(has_matl_station=False)) + hass, entry, async_add_entities, added = _setup_args(coordinator) + + await image_setup_entry(hass, entry, async_add_entities) + + assert _slot_images(added) == [] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_gated_sensors_added_when_the_capability_reports_in_later(): + """Same rule for every availability_fn-gated sensor, not just the images.""" + coordinator = _FakeCoordinator(None) + hass, entry, async_add_entities, added = _setup_args(coordinator) + + await sensor_setup_entry(hass, entry, async_add_entities) + before = {getattr(e, "_attr_unique_id", "") for e in added} + assert not any("ifs" in (uid or "") for uid in before) + + coordinator.publish(_machine_info(has_matl_station=True)) + + after = {getattr(e, "_attr_unique_id", "") for e in added} + assert any("ifs" in (uid or "") for uid in after - before) diff --git a/tests/unit/test_setup_entry.py b/tests/unit/test_setup_entry.py index a139a58..36db1da 100644 --- a/tests/unit/test_setup_entry.py +++ b/tests/unit/test_setup_entry.py @@ -25,10 +25,8 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME -@pytest.mark.unit -@pytest.mark.asyncio -async def test_async_setup_entry_uses_library_led_override_option(): - """The integration should pass LED override configuration into the API client options.""" +async def _run_setup(entry_options: dict): + """Run async_setup_entry with mocked collaborators; return (result, mocks).""" hass = Mock() hass.data = {} hass.config_entries = Mock() @@ -42,10 +40,7 @@ async def test_async_setup_entry_uses_library_led_override_option(): CONF_CHECK_CODE: "CHECK123", CONF_NAME: "Workshop Printer", } - entry.options = { - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - CONF_OVERRIDE_LED_AVAILABILITY: True, - } + entry.options = entry_options entry.add_update_listener = Mock(return_value=Mock()) entry.async_on_unload = Mock() @@ -67,17 +62,76 @@ async def test_async_setup_entry_uses_library_led_override_option(): ): result = await async_setup_entry(hass, entry) + return result, { + "hass": hass, + "entry": entry, + "client": client, + "client_cls": client_cls, + "options_cls": options_cls, + "options_sentinel": options_sentinel, + "machine_info": machine_info, + "coordinator": coordinator, + } + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_async_setup_entry_forces_led_capability_when_the_user_asks(): + """With the override enabled, the capability is forced on regardless of /product. + + This is the option's whole purpose: printers that report no LED but have one, + and users who fit an aftermarket light. + """ + result, mocks = await _run_setup( + { + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + CONF_OVERRIDE_LED_AVAILABILITY: True, + } + ) + assert result is True - options_cls.assert_called_once_with(led_control_override=True) - client_cls.assert_called_once_with( + mocks["options_cls"].assert_called_once_with(led_control_override=True) + mocks["client_cls"].assert_called_once_with( ip_address="192.168.1.100", serial_number="SN123456", check_code="CHECK123", - options=options_sentinel, + options=mocks["options_sentinel"], ) - client.cache_details.assert_called_once_with(machine_info) - client.send_product_command.assert_awaited_once() - coordinator.async_config_entry_first_refresh.assert_awaited_once() - hass.config_entries.async_forward_entry_setups.assert_awaited_once() - entry.async_on_unload.assert_called_once() - assert hass.data[DOMAIN][entry.entry_id]["client"] is client + mocks["client"].cache_details.assert_called_once_with(mocks["machine_info"]) + mocks["client"].send_product_command.assert_awaited_once() + mocks["coordinator"].async_config_entry_first_refresh.assert_awaited_once() + mocks["hass"].config_entries.async_forward_entry_setups.assert_awaited_once() + mocks["entry"].async_on_unload.assert_called_once() + assert mocks["hass"].data[DOMAIN][mocks["entry"].entry_id]["client"] is mocks["client"] + + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("options", "case"), + [ + ({CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, "option never set"), + ( + { + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + CONF_OVERRIDE_LED_AVAILABILITY: False, + }, + "option explicitly off", + ), + ], +) +async def test_async_setup_entry_does_not_override_led_capability_when_off(options, case): + """With the override off, send None - never False. + + Regression test. `led_control_override` is tri-state: None means "no + override", but False means "force the capability OFF". The option was passed + straight through, so every user who never touched it - the default - sent + False and vetoed the printer's own correct capability report. The LED switch + was greyed out on every model, and the library refused set_led_on/off + internally, which made enabling the override look like the only way to get a + working switch. It was: True was the only value that got past the veto. + """ + result, mocks = await _run_setup(options) + + assert result is True, case + mocks["options_cls"].assert_called_once_with(led_control_override=None) diff --git a/tests/unit/test_switch_availability.py b/tests/unit/test_switch_availability.py index 4b62983..5c21f72 100644 --- a/tests/unit/test_switch_availability.py +++ b/tests/unit/test_switch_availability.py @@ -13,7 +13,9 @@ mock_homeassistant() +from custom_components.flashforge.const import DOMAIN from custom_components.flashforge.switch import SWITCHES +from custom_components.flashforge.switch import async_setup_entry as switch_setup_entry def _switch_by_key(key: str): @@ -36,19 +38,66 @@ def test_led_switch_availability_uses_effective_client_capability(): assert led_switch.availability_fn(client) is True +def _client(*, is_pro=False, is_creator5=False, is_creator5_pro=False): + client = Mock() + client.led_control = True + client.is_pro = is_pro + client.is_creator5 = is_creator5 + client.is_creator5_pro = is_creator5_pro + return client + + +async def _setup_switches(client): + hass = Mock() + entry = Mock() + entry.entry_id = "entry-1" + coordinator = Mock() + coordinator.device_model = "Printer" + coordinator.serial_number = "SN123456" + coordinator.firmware_version = "1.9.4" + hass.data = { + DOMAIN: { + entry.entry_id: {"coordinator": coordinator, "client": client, "name": "Printer"} + } + } + + added = [] + await switch_setup_entry(hass, entry, added.extend) + return {entity.entity_description.key for entity in added} + + @pytest.mark.unit def test_camera_switch_availability_uses_pro_detection(): - """Camera switch availability should depend on either Pro-model client flag.""" + """Camera switch availability should depend on the Pro-model client flag.""" camera_switch = _switch_by_key("camera") - client = Mock() - client.is_pro = False - client.is_creator5_pro = False - assert camera_switch.availability_fn(client) is False + assert camera_switch.availability_fn(_client()) is False + assert camera_switch.availability_fn(_client(is_pro=True)) is True + - client.is_pro = True - assert camera_switch.availability_fn(client) is True +@pytest.mark.unit +@pytest.mark.asyncio +async def test_camera_switch_is_not_created_on_the_creator_5_series(): + """The Creator 5 API cannot stop the stream, so the switch must not exist. - client.is_pro = False - client.is_creator5_pro = True - assert camera_switch.availability_fn(client) is True + Confirmed on a Creator 5 Pro (firmware 1.9.4): streamCtrl_cmd reports + success and the stream keeps serving frames, and `cameraStreamUrl` stays + populated so the switch snaps back to `on` on the next poll. An available + switch that silently does nothing is worse than an absent one. + """ + assert "camera" not in await _setup_switches(_client(is_creator5=True)) + assert "camera" not in await _setup_switches(_client(is_creator5_pro=True)) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_camera_switch_is_still_created_on_other_models(): + """Only the Creator 5 series loses the switch; the 5M Pro keeps it.""" + assert "camera" in await _setup_switches(_client(is_pro=True)) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_led_switch_is_created_on_every_model(): + """Nothing else gets filtered out by the supported_fn pass.""" + assert "led" in await _setup_switches(_client(is_creator5_pro=True))