From 229a1e46acf4fb0549c546589b4d9a0d7ef3fa6f Mon Sep 17 00:00:00 2001 From: RedAces Date: Sat, 25 Jul 2026 20:47:15 +0200 Subject: [PATCH 01/10] list local printer files and start prints The printer's stored files were not visible in Home Assistant and prints could only be started from the printer, the slicer, or the FlashForge app. Adds: - select._print_file - lists the files the printer reports via the HTTP /gcodeList endpoint and records which one to print. Per-file print time, filament weight, tool count, and the Material Station flag are exposed through extra_state_attributes["files"]. - button._print_selected_file - starts the selected file and stays unavailable while nothing is selected. - flashforge.print_file service on the print file select, with optional file_name and leveling_before_print, so automations can start any file on the printer - including files outside the reported list. - "Level the bed before starting a print" option (default off), used as the default for the button and the service. --- AGENTS.md | 10 +- CHANGELOG.md | 12 + CLAUDE.md | 31 +- README.md | 45 ++- custom_components/flashforge/__init__.py | 18 +- custom_components/flashforge/button.py | 79 ++++- custom_components/flashforge/config_flow.py | 8 + custom_components/flashforge/const.py | 11 + custom_components/flashforge/coordinator.py | 58 +++- custom_components/flashforge/diagnostics.py | 10 +- custom_components/flashforge/print_job.py | 178 ++++++++++++ custom_components/flashforge/select.py | 152 +++++++++- custom_components/flashforge/services.yaml | 15 + custom_components/flashforge/strings.json | 23 +- .../flashforge/translations/en.json | 23 +- pyproject.toml | 5 + requirements-test.txt | 3 + tests/ha_mocks.py | 17 +- tests/unit/test_file_list_coordinator.py | 100 +++++++ tests/unit/test_print_file_entities.py | 237 +++++++++++++++ tests/unit/test_print_job.py | 271 ++++++++++++++++++ tests/unit/test_setup_entry.py | 13 + 22 files changed, 1285 insertions(+), 34 deletions(-) create mode 100644 custom_components/flashforge/print_job.py create mode 100644 custom_components/flashforge/services.yaml create mode 100644 pyproject.toml create mode 100644 tests/unit/test_file_list_coordinator.py create mode 100644 tests/unit/test_print_file_entities.py create mode 100644 tests/unit/test_print_job.py diff --git a/AGENTS.md b/AGENTS.md index 6a64601..eac95d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,8 @@ 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:** 58 total (38 sensors, 5 binary sensors, 2 switches, 2 selects, 5 buttons, 1 camera, 5 images — the g-code thumbnail plus 4 Material Station slot color swatches). +- **Services:** `flashforge.print_file` — starts a file already stored on the printer (entity service on the print file select). - **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`. @@ -32,7 +33,7 @@ Working agreement for AI assistants contributing to `ff-5mp-hass`. - Prepare GitHub releases and ensure README badges reflect the latest status. ## Shared Guidelines -- Respect the HTTP-only policy—introduce TCP/G-code handling only by extending `flashforge-python-api`. +- Respect the HTTP-only policy—introduce TCP/G-code handling only by extending `flashforge-python-api`. The file list therefore comes from `files.get_recent_file_list()` (HTTP `/gcodeList`, most recent files only), never from the TCP directory listing. - Treat the coordinator as the single source of truth; entities should not cache printer state. - Update `strings.json` and `translations/en.json` alongside config-flow text changes. - Keep imports ordered and comments purposeful. @@ -64,8 +65,9 @@ Working agreement for AI assistants contributing to `ff-5mp-hass`. - Sensors: machine status, nozzle temps/targets, bed temps/targets, progress, file, current/total layers, elapsed/remaining time, filament length/weight, print speed, z offset, move mode, nozzle size, filament type. - Binary sensors: printing, online, error, paused. - Switches: LED and camera power (may show unavailable if unsupported). - - Select: filtration mode (may show unavailable if unsupported). - - Buttons: pause, resume, cancel, clear status. + - Select: filtration mode (may show unavailable if unsupported); print file (lists the printer's files). + - Buttons: pause, resume, cancel, clear status, print selected file. + - Service: `flashforge.print_file` (defaults to the selected file, accepts any file name on the printer). - Camera: entity exists and becomes available when the printer reports an OEM stream URL. 4. **Controls** – Exercise switches and buttons; confirm state refreshes and coordinator remains healthy. 5. **Resilience** – Temporarily disrupt connectivity (e.g., disable LAN mode) and confirm graceful error handling and recovery in Home Assistant logs. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0baa382..0298d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Local file list and print start.** The files stored on the printer are now visible in Home Assistant and a print can be started from them: + - `select._print_file` lists the printer's files (from the HTTP `/gcodeList` endpoint) and records which one to print. Each file's print time, filament weight, tool count, and Material Station flag are exposed via `extra_state_attributes["files"]` for cards and templates. + - `button._print_selected_file` starts the selected file. It stays unavailable while no file is selected. + - `flashforge.print_file` service (targets the print file select entity) with optional `file_name` and `leveling_before_print` fields, so automations can start any file on the printer — including ones outside the reported list. + - New option **"Level the bed before starting a print"** (default off) supplies the default for the button and the service. + - Material Station files (AD5X / Creator 5 series) are started with the per-tool mappings derived from the file's own tool data plus the colors the printer reports for the loaded slots. When that data is incomplete the print is refused with an error telling the user to start it from the slicer instead of guessing a mapping. +- The file list is polled by its own coordinator every 60 s (independent of the machine-state interval) and is included in diagnostics. + +### Notes +- The printer's HTTP API only reports its most recent files (10 on current firmware); older files can still be printed by passing `file_name` to `flashforge.print_file`. The TCP full-directory listing is deliberately not used — this integration stays HTTP-only. + ## [1.3.1] - 2026-07-23 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 27a4c2a..a87fc60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,8 @@ Guidance for AI coding assistants working in this repository. ## Current State (May 2026) - Integration **version 1.3.0** (in-flight; not yet tagged). - 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). +- Entities shipped: **58 total** (38 sensors, 5 binary sensors, 2 switches, 5 buttons, 2 selects, 1 MJPEG camera, 5 images — the g-code thumbnail plus 4 Material Station slot color swatches). +- One service: `flashforge.print_file` (entity service on the print file select). - Diagnostics download supported (`diagnostics.py`), with credentials and identifiers redacted. - Reauthentication and reconfigure flows supported in addition to the original setup paths. - UI config flow supports automatic discovery, manual entry, credential validation, and an adjustable polling interval (5–300 s, default 10 s). @@ -49,7 +50,7 @@ Treat this file as the living source of truth for workflows and expectations—u - Automatic printer discovery via UDP broadcast with multi-printer selection. - Manual fallback for IP/serial/check-code entry. - Credential validation before config entry creation. - - Options flow exposes adjustable polling (5–300 s). + - Options flow exposes the LED-availability override, the pre-print bed-leveling default, and adjustable polling (5–300 s). - **Monitoring** - 38 sensors covering status, temperatures (per-toolhead on the Creator 5 series, plus a heated chamber), progress, layers, timing, filament metrics, fan speeds, air quality (5M Pro / Creator 5 Pro TVOC), active Material Station slot, print completion time, lifetime stats, plus diagnostic sensors (`firmware_version`, `free_disk_space`, `ip_address`, `error_code`). - 5 binary sensors tracking printing, online, error, paused, and door-open (Creator 5 Pro only) states. @@ -60,10 +61,11 @@ Treat this file as the living source of truth for workflows and expectations—u - 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). - Pause / resume / cancel / clear-status buttons with post-action refresh. + - Local file printing: a `select` listing the printer's files, a "print selected file" button, and the `flashforge.print_file` service. - MJPEG camera entity targeting `http://:8080/?action=stream`. - **Architecture** - - HTTP API only (`FlashForgeClient.info/control/job_control`). - - `DataUpdateCoordinator` refresh loop with error recovery and client cleanup. + - HTTP API only (`FlashForgeClient.info/control/job_control/files`). + - `DataUpdateCoordinator` refresh loop with error recovery and client cleanup; a second, slower coordinator for the file list. - Unique IDs built from config entry, serial number, and entity keys. ## Installation Quick Start @@ -76,12 +78,14 @@ Treat this file as the living source of truth for workflows and expectations—u ## Core Modules and Responsibilities - `__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. +- `coordinator.py` – `FlashForgeDataUpdateCoordinator` wrapping `FlashForgeClient.info.get()` with graceful error handling and cleanup, plus `FlashForgeFileListCoordinator` polling `files.get_recent_file_list()` on the slower `FILE_LIST_SCAN_INTERVAL` (it also holds the file selected for printing and never closes the shared client). - `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). -- `button.py` – Pause / resume / cancel / clear-status commands; request a refresh after each action. +- `select.py` – Filtration mode select (Off / Internal / External, AD5X only) and the print file select (`FlashForgeFileSelect`, options = the printer's file list, metadata in `extra_state_attributes`). Also registers the `flashforge.print_file` entity service. +- `button.py` – Pause / resume / cancel / clear-status commands plus `FlashForgePrintSelectedFileButton`; request a refresh after each action. +- `print_job.py` – Per-model dispatch for starting a file already on the printer (`start_creator5_job` / AD5X single+multi color / `print_local_file`) and `build_material_mappings()`, which derives Material Station mappings from the file's tool data plus the printer's slot colors. Raises `HomeAssistantError` instead of guessing an incomplete mapping. +- `services.yaml` – Service definition for `flashforge.print_file` (keep in sync with the `services` block in `strings.json`). - `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. - `diagnostics.py` – HA diagnostics download payload, with `check_code`, `serial_number`, MAC/IP, and cloud registration codes redacted. @@ -282,7 +286,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 (147 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 @@ -294,10 +298,14 @@ pytest tests/unit/test_sensor_value_functions.py -v - `tests/unit/test_platform_registration.py` – platform list sanity - `tests/unit/test_select_availability.py` – filtration select availability - `tests/unit/test_switch_availability.py` – LED switch availability with override +- `tests/unit/test_file_list_coordinator.py` – local file list fetch, filtering, and error paths +- `tests/unit/test_print_job.py` – per-model print-start dispatch and Material Station mapping +- `tests/unit/test_print_file_entities.py` – print file select + print button behavior **Test dependencies** (`requirements-test.txt`): - Core: `pytest`, `pytest-asyncio`, `pytest-cov` - Snapshot testing: `syrupy` (for future use) +- Schema validation: `voluptuous` (config flow + service schemas) - API library: `flashforge-python-api` (editable install for development) - Network: `netifaces` (for discovery tests) - **Explicitly excludes** `homeassistant` and `pytest-homeassistant-custom-component` (Unix-only) @@ -340,8 +348,9 @@ pytest tests/unit/test_sensor_value_functions.py -v - 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. - Switch: LED (may show unavailable on unsupported models unless override is enabled). - - Select: filtration mode — Off / Internal / External (AD5X only). - - Buttons: pause, resume, cancel, clear status. + - Select: filtration mode — Off / Internal / External (AD5X only); print file — lists the printer's files. + - Buttons: pause, resume, cancel, clear status, print selected file (unavailable until a file is selected). + - Service: `flashforge.print_file` on the print file select, with and without `file_name` / `leveling_before_print`. - 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. @@ -354,7 +363,7 @@ pytest tests/unit/test_sensor_value_functions.py -v - **Hardware caveat** – Full verification requires a FlashForge printer with LAN mode enabled; simulated runs only confirm flow logic. ## 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. +- **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. This is why the file list uses `files.get_recent_file_list()` (HTTP `/gcodeList`, most recent files only) instead of `files.get_file_list()`, which falls back to a TCP/8899 directory listing on the 5M family. The `flashforge.print_file` service accepts a free-form `file_name` so files outside that list stay printable. - **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. diff --git a/README.md b/README.md index 03de699..e3f2a95 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ | **Prerequisites: Enable LAN Mode** | Before adding the integration, you must enable LAN mode on your FlashForge printer:

1. On the printer touchscreen, go to **Settings** → **Network** → **LAN Mode**
2. Enable LAN mode
3. Note the **Check Code** (8-digit code) - you'll need this for setup

[Video Tutorial](https://www.youtube.com/watch?v=krdEGccZuKo) | | **Option 1: Automatic Discovery (Recommended)** | 1. Go to **Settings** → **Devices & Services** → **Integrations**
2. Click **+ Add Integration**
3. Search for **"FlashForge"**
4. Select your AD5X, Adventurer 5M, Adventurer 5M Pro, Creator 5, or Creator 5 Pro from the discovered list
5. Enter your printer's **Check Code**
6. Click **Submit** | | **Option 2: Manual Configuration** | 1. Go to **Settings** → **Devices & Services** → **Integrations**
2. Click **+ Add Integration**
3. Search for **"FlashForge"**
4. Select **"Configure Manually"**
5. Enter:
   • **IP Address**: Your printer's IP (e.g., `192.168.1.100`)
   • **Printer Name**: Friendly name (optional)
   • **Serial Number**: From the printer settings screen. **Must include the `SN` prefix** (e.g. `SN123456789`) — the `SN` printed on the back sticker is part of the value you enter, not just a label
   • **Check Code**: From LAN mode settings
6. Click **Submit** | -| **Configuration Options** | After setup, you can adjust settings:

1. Go to **Settings** → **Devices & Services** → **FlashForge**
2. Click **⋮** on your printer → **Configure**
3. **Scan Interval**: Update frequency in seconds (5-300, default: 10) | +| **Configuration Options** | After setup, you can adjust settings:

1. Go to **Settings** → **Devices & Services** → **FlashForge**
2. Click **⋮** on your printer → **Configure**
3. **Scan Interval**: Update frequency in seconds (5-300, default: 10)
4. **Always show LED switch**: Override the printer's LED capability check
5. **Level the bed before starting a print**: Applies to prints started from Home Assistant (default: off) | | **LED Switch Override** | If your printer's LED switch is not detected but you know it is supported, enable **Always show LED switch** in the options. This will force the LED switch to appear regardless of printer capability checks. | @@ -262,6 +262,7 @@ | Entity | Description | Options | Availability | |--------|-------------|---------|--------------| | `select.flashforge_filtration_mode` | Control filtration system | Off, Internal, External | 5M Pro / Creator 5 Pro | +| `select.flashforge_print_file` | Files stored on the printer; picks the one to print | Printer's file list | All Models | @@ -279,6 +280,7 @@ | `button.flashforge_resume_print` | Resume paused print job | | `button.flashforge_cancel_print` | Cancel and abort print job | | `button.flashforge_clear_status` | Clear printer status/errors | +| `button.flashforge_print_selected_file` | Start printing the file picked on `select.flashforge_print_file` | @@ -298,6 +300,47 @@ +
+

Printing Files Stored on the Printer

+
+ +`select.flashforge_print_file` lists the files on the printer and records which one you want +to print; `button.flashforge_print_selected_file` starts it. The button stays unavailable +until a file is selected. + +The printer's HTTP API reports its **most recent files** (10 on current firmware), so the +dropdown is not a full directory listing. Files outside that list can still be printed by +passing their name to the service: + +```yaml +action: flashforge.print_file +target: + entity_id: select.flashforge_print_file +data: + file_name: benchy.3mf # optional, defaults to the selected file + leveling_before_print: true # optional, defaults to the integration option +``` + +Each file's metadata is available for cards and templates: + +```yaml +{{ state_attr('select.flashforge_print_file', 'files') }} +# [{'name': 'benchy.3mf', 'printing_time': 3600, 'filament_weight': 25.5, +# 'tool_count': 1, 'uses_material_station': False}, ...] +``` + +**Bed leveling** before a print is off by default and can be enabled in the integration's +**Configure** dialog, or per call via `leveling_before_print`. + +**Material Station files** (AD5X / Creator 5 series) are started with the per-tool material +mappings derived from the file's own tool data combined with the colors the printer reports +for the loaded slots. If that information is incomplete, the print is refused with an error +rather than mapping materials by guesswork — start such a print from your slicer or the +FlashForge app instead. + +The file list is refreshed every 60 seconds. To refresh it immediately (e.g. right after an +upload), call `homeassistant.update_entity` on `select.flashforge_print_file`. +

Usage Examples

diff --git a/custom_components/flashforge/__init__.py b/custom_components/flashforge/__init__.py index 09a6be5..be39cc0 100644 --- a/custom_components/flashforge/__init__.py +++ b/custom_components/flashforge/__init__.py @@ -18,7 +18,7 @@ DEFAULT_SCAN_INTERVAL, DOMAIN, ) -from .coordinator import FlashForgeDataUpdateCoordinator +from .coordinator import FlashForgeDataUpdateCoordinator, FlashForgeFileListCoordinator from .util import async_close_flashforge_client _LOGGER = logging.getLogger(__name__) @@ -90,9 +90,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Fetch initial data await coordinator.async_config_entry_first_refresh() - # Store coordinator and client + # The file list lives on its own slower schedule. A failure here must not + # block setup - only the file entities depend on it. + file_coordinator = FlashForgeFileListCoordinator( + hass=hass, + client=client, + name=name, + ) + await file_coordinator.async_refresh() + + # Store coordinators and client hass.data[DOMAIN][entry.entry_id] = { "coordinator": coordinator, + "file_coordinator": file_coordinator, "client": client, "name": name, } @@ -112,8 +122,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - # Clean up coordinator and client + # Clean up coordinators and client data = hass.data[DOMAIN].pop(entry.entry_id) + file_coordinator: FlashForgeFileListCoordinator = data["file_coordinator"] + await file_coordinator.async_shutdown() coordinator: FlashForgeDataUpdateCoordinator = data["coordinator"] await coordinator.async_shutdown() diff --git a/custom_components/flashforge/button.py b/custom_components/flashforge/button.py index bffbc8e..47f450b 100644 --- a/custom_components/flashforge/button.py +++ b/custom_components/flashforge/button.py @@ -11,11 +11,17 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import FlashForgeDataUpdateCoordinator +from .const import ( + CONF_LEVELING_BEFORE_PRINT, + DEFAULT_LEVELING_BEFORE_PRINT, + DOMAIN, +) +from .coordinator import FlashForgeDataUpdateCoordinator, FlashForgeFileListCoordinator +from .print_job import async_start_local_print from .util import build_device_info _LOGGER = logging.getLogger(__name__) @@ -65,13 +71,21 @@ async def async_setup_entry( coordinator: FlashForgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ "coordinator" ] + file_coordinator: FlashForgeFileListCoordinator = hass.data[DOMAIN][entry.entry_id][ + "file_coordinator" + ] client: FlashForgeClient = hass.data[DOMAIN][entry.entry_id]["client"] printer_name: str = hass.data[DOMAIN][entry.entry_id]["name"] - entities = [ + entities: list[ButtonEntity] = [ FlashForgeButton(coordinator, client, description, printer_name, entry.entry_id) for description in BUTTONS ] + entities.append( + FlashForgePrintSelectedFileButton( + coordinator, file_coordinator, client, entry, printer_name + ) + ) async_add_entities(entities) @@ -113,3 +127,62 @@ async def async_press(self) -> None: _LOGGER.error( "Error pressing button %s: %s", self.entity_description.name, err ) + + +class FlashForgePrintSelectedFileButton( + CoordinatorEntity[FlashForgeFileListCoordinator], ButtonEntity +): + """Starts the print of the file picked on the print file select entity.""" + + _attr_has_entity_name = True + _attr_translation_key = "print_selected_file" + _attr_icon = "mdi:printer" + + def __init__( + self, + machine_coordinator: FlashForgeDataUpdateCoordinator, + coordinator: FlashForgeFileListCoordinator, + client: FlashForgeClient, + entry: ConfigEntry, + printer_name: str, + ) -> None: + """Initialize the print button.""" + super().__init__(coordinator) + self._machine_coordinator = machine_coordinator + self._client = client + self._entry = entry + self._attr_unique_id = f"{entry.entry_id}_print_selected_file" + self._attr_device_info = build_device_info( + machine_coordinator, printer_name, entry.entry_id + ) + + @property + def available(self) -> bool: + """Return if a file is selected and the printer is reachable.""" + selected = self.coordinator.selected_file + return ( + self.coordinator.last_update_success + and self._machine_coordinator.last_update_success + and selected is not None + and selected in self.coordinator.file_names + ) + + async def async_press(self) -> None: + """Start printing the selected file.""" + selected = self.coordinator.selected_file + if selected is None: + raise ServiceValidationError("No file selected to print") + + leveling_before_print = self._entry.options.get( + CONF_LEVELING_BEFORE_PRINT, DEFAULT_LEVELING_BEFORE_PRINT + ) + + await async_start_local_print( + self._client, + selected, + leveling_before_print=bool(leveling_before_print), + file_entry=self.coordinator.entry_for(selected), + machine_info=self._machine_coordinator.data, + ) + + await self._machine_coordinator.async_request_refresh() diff --git a/custom_components/flashforge/config_flow.py b/custom_components/flashforge/config_flow.py index 49a2209..a050e3c 100644 --- a/custom_components/flashforge/config_flow.py +++ b/custom_components/flashforge/config_flow.py @@ -15,8 +15,10 @@ from .const import ( CONF_CHECK_CODE, + CONF_LEVELING_BEFORE_PRINT, CONF_SCAN_INTERVAL, CONF_SERIAL_NUMBER, + DEFAULT_LEVELING_BEFORE_PRINT, DEFAULT_NAME, DEFAULT_SCAN_INTERVAL, DOMAIN, @@ -487,6 +489,12 @@ async def async_step_init( CONF_OVERRIDE_LED_AVAILABILITY, default=self.config_entry.options.get(CONF_OVERRIDE_LED_AVAILABILITY, False), ): bool, + vol.Optional( + CONF_LEVELING_BEFORE_PRINT, + default=self.config_entry.options.get( + CONF_LEVELING_BEFORE_PRINT, DEFAULT_LEVELING_BEFORE_PRINT + ), + ): bool, } ), ) diff --git a/custom_components/flashforge/const.py b/custom_components/flashforge/const.py index 68feefb..07f0887 100644 --- a/custom_components/flashforge/const.py +++ b/custom_components/flashforge/const.py @@ -7,12 +7,23 @@ CONF_CHECK_CODE = "check_code" CONF_SCAN_INTERVAL = "scan_interval" CONF_OVERRIDE_LED_AVAILABILITY = "override_led_availability" +CONF_LEVELING_BEFORE_PRINT = "leveling_before_print" # Default values DEFAULT_NAME = "FlashForge Printer" DEFAULT_SCAN_INTERVAL = 10 # seconds DEFAULT_HTTP_PORT = 8898 DEFAULT_CAMERA_PORT = 8080 +DEFAULT_LEVELING_BEFORE_PRINT = False + +# The printer's local file list changes only when a file is uploaded or deleted, +# so it is polled on its own, slower schedule than the machine state. +FILE_LIST_SCAN_INTERVAL = 60 # seconds + +# Services +SERVICE_PRINT_FILE = "print_file" +ATTR_FILE_NAME = "file_name" +ATTR_LEVELING_BEFORE_PRINT = "leveling_before_print" # Entity keys ATTR_MACHINE_STATUS = "machine_status" diff --git a/custom_components/flashforge/coordinator.py b/custom_components/flashforge/coordinator.py index 6df942f..691cffb 100644 --- a/custom_components/flashforge/coordinator.py +++ b/custom_components/flashforge/coordinator.py @@ -5,12 +5,12 @@ import logging from flashforge import FlashForgeClient -from flashforge.models import FFMachineInfo +from flashforge.models import FFGcodeFileEntry, FFMachineInfo from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, PRINTER_MODEL_NAMES +from .const import DOMAIN, FILE_LIST_SCAN_INTERVAL, PRINTER_MODEL_NAMES from .util import async_close_flashforge_client _LOGGER = logging.getLogger(__name__) @@ -73,3 +73,57 @@ async def _async_update_data(self) -> FFMachineInfo: async def async_shutdown(self) -> None: """Shutdown the coordinator and cleanup resources.""" await async_close_flashforge_client(self.client) + + +class FlashForgeFileListCoordinator(DataUpdateCoordinator[list[FFGcodeFileEntry]]): + """Class to manage fetching the printer's local g-code file list. + + Uses the HTTP ``/gcodeList`` endpoint, which reports the printer's most + recent files (10 on current firmware) with their metadata. It is polled on a + slower schedule than the machine state because the list only changes when a + file is uploaded or removed. + + The client is owned by :class:`FlashForgeDataUpdateCoordinator`, so this + coordinator never closes it. + """ + + def __init__( + self, + hass: HomeAssistant, + client: FlashForgeClient, + name: str, + ) -> None: + """Initialize the file list coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{name}_files", + update_interval=timedelta(seconds=FILE_LIST_SCAN_INTERVAL), + ) + self.client = client + self.printer_name = name + self.selected_file: str | None = None + + @property + def file_names(self) -> list[str]: + """Return the names of the files currently on the printer.""" + return [entry.gcode_file_name for entry in self.data or []] + + def entry_for(self, file_name: str) -> FFGcodeFileEntry | None: + """Return the file list entry for a file name, if the printer reported one.""" + for entry in self.data or []: + if entry.gcode_file_name == file_name: + return entry + return None + + async def _async_update_data(self) -> list[FFGcodeFileEntry]: + """Fetch the local file list from the printer.""" + try: + entries = await self.client.files.get_recent_file_list() + except Exception as err: + _LOGGER.error( + "Error fetching file list from printer %s: %s", self.printer_name, err + ) + raise UpdateFailed(f"Error fetching file list: {err}") from err + + return [entry for entry in entries or [] if entry.gcode_file_name] diff --git a/custom_components/flashforge/diagnostics.py b/custom_components/flashforge/diagnostics.py index a888c6d..6fc6763 100644 --- a/custom_components/flashforge/diagnostics.py +++ b/custom_components/flashforge/diagnostics.py @@ -12,7 +12,7 @@ CONF_SERIAL_NUMBER, DOMAIN, ) -from .coordinator import FlashForgeDataUpdateCoordinator +from .coordinator import FlashForgeDataUpdateCoordinator, FlashForgeFileListCoordinator TO_REDACT_ENTRY = {CONF_CHECK_CODE, CONF_SERIAL_NUMBER} TO_REDACT_DATA = { @@ -47,6 +47,9 @@ async def async_get_config_entry_diagnostics( coordinator: FlashForgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ "coordinator" ] + file_coordinator: FlashForgeFileListCoordinator = hass.data[DOMAIN][entry.entry_id][ + "file_coordinator" + ] client = hass.data[DOMAIN][entry.entry_id]["client"] machine_info = async_redact_data( @@ -68,6 +71,11 @@ async def async_get_config_entry_diagnostics( ), "device_model": coordinator.device_model, }, + "file_list": { + "last_update_success": file_coordinator.last_update_success, + "files": file_coordinator.file_names, + "selected_file": file_coordinator.selected_file, + }, "capabilities": { "led_control": getattr(client, "led_control", None), "filtration_control": getattr(client, "filtration_control", None), diff --git a/custom_components/flashforge/print_job.py b/custom_components/flashforge/print_job.py new file mode 100644 index 0000000..5e85824 --- /dev/null +++ b/custom_components/flashforge/print_job.py @@ -0,0 +1,178 @@ +"""Starting prints of files that are already stored on the printer. + +The printer exposes a single ``/printGcode`` endpoint, but the payload it expects +differs per model family. The library ships one method per family, so this module +holds the dispatch plus the Material Station mapping derivation shared by the +select entity, the button, and the ``flashforge.print_file`` service. +""" +from __future__ import annotations + +import logging +import re +from typing import Any + +from flashforge import FlashForgeClient +from flashforge.models import ( + AD5XLocalJobParams, + AD5XMaterialMapping, + AD5XSingleColorJobParams, + Creator5JobParams, + FFGcodeFileEntry, + FFMachineInfo, +) + +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +_LOGGER = logging.getLogger(__name__) + +_HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$") + + +def _is_hex_color(value: Any) -> bool: + """Return True when the value is a ``#RRGGBB`` string the printer accepts.""" + return isinstance(value, str) and bool(_HEX_COLOR.match(value)) + + +def _station_slot(machine_info: FFMachineInfo | None, slot_id: int) -> Any | None: + """Return the Material Station slot info for a slot id, when reported.""" + station = getattr(machine_info, "matl_station_info", None) + for slot in getattr(station, "slot_infos", None) or []: + if getattr(slot, "slot_id", None) == slot_id: + return slot + return None + + +def needs_material_station(file_entry: FFGcodeFileEntry | None) -> bool: + """Return True when the file was sliced for the Material Station.""" + return bool( + file_entry is not None + and getattr(file_entry, "use_matl_station", False) + and getattr(file_entry, "gcode_tool_datas", None) + ) + + +def build_material_mappings( + file_entry: FFGcodeFileEntry, machine_info: FFMachineInfo | None +) -> list[AD5XMaterialMapping]: + """Derive the per-tool Material Station mappings for a file. + + The slot assignment comes from the file itself (the slicer stores a slot per + tool); the slot color is taken from the printer's current Material Station + report so the firmware sees the color of the filament actually loaded. + + Raises: + ServiceValidationError: If the file's tool data is incomplete, in which + case the mapping cannot be derived and the print must be started from + the slicer or the FlashForge app. + """ + mappings: list[AD5XMaterialMapping] = [] + + for tool in file_entry.gcode_tool_datas or []: + slot = _station_slot(machine_info, tool.slot_id) + slot_color = getattr(slot, "material_color", "") or "" + tool_color = tool.material_color or "" + if not _is_hex_color(tool_color): + tool_color = slot_color + if not _is_hex_color(slot_color): + slot_color = tool_color + material = (tool.material_name or getattr(slot, "material_name", "") or "").strip() + + if ( + not 1 <= tool.slot_id <= 4 + or not material + or not _is_hex_color(tool_color) + or not _is_hex_color(slot_color) + ): + raise ServiceValidationError( + f"Cannot derive the Material Station mapping for tool {tool.tool_id} of " + f"'{file_entry.gcode_file_name}' (slot {tool.slot_id}, material " + f"'{material or 'unknown'}'). Start this multi-material print from your " + "slicer or the FlashForge app instead." + ) + + mappings.append( + AD5XMaterialMapping( + tool_id=tool.tool_id, + slot_id=tool.slot_id, + material_name=material, + tool_material_color=tool_color, + slot_material_color=slot_color, + ) + ) + + return mappings + + +async def async_start_local_print( + client: FlashForgeClient, + file_name: str, + *, + leveling_before_print: bool, + file_entry: FFGcodeFileEntry | None = None, + machine_info: FFMachineInfo | None = None, +) -> None: + """Start a print of a file already stored on the printer. + + ``file_entry`` is the printer's file list entry for ``file_name``, when known; + it is what tells us whether the file needs Material Station mappings. Without + it the file is started as a single-material print. + + Raises: + ServiceValidationError: If no file was given or the mappings cannot be + derived from the file's tool data. + HomeAssistantError: If the request fails or the printer rejects it. + """ + file_name = (file_name or "").strip() + if not file_name: + raise ServiceValidationError("No file name given to print") + + mappings: list[AD5XMaterialMapping] = [] + if needs_material_station(file_entry): + mappings = build_material_mappings(file_entry, machine_info) # type: ignore[arg-type] + + _LOGGER.debug( + "Starting print of %s (leveling=%s, material mappings=%d)", + file_name, + leveling_before_print, + len(mappings), + ) + + try: + if getattr(client, "is_creator5", False): + started = await client.job_control.start_creator5_job( + Creator5JobParams( + file_name=file_name, + leveling_before_print=leveling_before_print, + material_mappings=mappings or None, + ) + ) + elif getattr(client, "is_ad5x", False): + if mappings: + started = await client.job_control.start_ad5x_multi_color_job( + AD5XLocalJobParams( + file_name=file_name, + leveling_before_print=leveling_before_print, + material_mappings=mappings, + ) + ) + else: + started = await client.job_control.start_ad5x_single_color_job( + AD5XSingleColorJobParams( + file_name=file_name, + leveling_before_print=leveling_before_print, + ) + ) + else: + started = await client.job_control.print_local_file( + file_name, leveling_before_print + ) + except HomeAssistantError: + raise + except Exception as err: # noqa: BLE001 - upstream may raise broad exceptions + raise HomeAssistantError(f"Error starting print of '{file_name}': {err}") from err + + if not started: + raise HomeAssistantError( + f"The printer rejected the request to print '{file_name}'. Make sure the " + "file is still on the printer and the printer is idle." + ) diff --git a/custom_components/flashforge/select.py b/custom_components/flashforge/select.py index b355658..8a9d50f 100644 --- a/custom_components/flashforge/select.py +++ b/custom_components/flashforge/select.py @@ -8,19 +8,36 @@ from flashforge import FlashForgeClient from flashforge.models import FFMachineInfo +import voluptuous as vol from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import FlashForgeDataUpdateCoordinator +from .const import ( + ATTR_FILE_NAME, + ATTR_LEVELING_BEFORE_PRINT, + CONF_LEVELING_BEFORE_PRINT, + DEFAULT_LEVELING_BEFORE_PRINT, + DOMAIN, + SERVICE_PRINT_FILE, +) +from .coordinator import FlashForgeDataUpdateCoordinator, FlashForgeFileListCoordinator +from .print_job import async_start_local_print from .util import build_device_info _LOGGER = logging.getLogger(__name__) +PRINT_FILE_SCHEMA = { + vol.Optional(ATTR_FILE_NAME): cv.string, + vol.Optional(ATTR_LEVELING_BEFORE_PRINT): cv.boolean, +} + @dataclass class FlashForgeSelectEntityDescription(SelectEntityDescription): @@ -54,6 +71,20 @@ class FlashForgeSelectEntityDescription(SelectEntityDescription): ) +async def _async_print_file_service(entity: Entity, call: ServiceCall) -> None: + """Handle the ``flashforge.print_file`` entity service.""" + if not isinstance(entity, FlashForgeFileSelect): + raise ServiceValidationError( + f"{SERVICE_PRINT_FILE} must target a FlashForge print file entity, " + f"got {entity.entity_id}" + ) + + await entity.async_print_file( + call.data.get(ATTR_FILE_NAME), + call.data.get(ATTR_LEVELING_BEFORE_PRINT), + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -63,16 +94,28 @@ async def async_setup_entry( coordinator: FlashForgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ "coordinator" ] + file_coordinator: FlashForgeFileListCoordinator = hass.data[DOMAIN][entry.entry_id][ + "file_coordinator" + ] client: FlashForgeClient = hass.data[DOMAIN][entry.entry_id]["client"] printer_name: str = hass.data[DOMAIN][entry.entry_id]["name"] - entities = [ + entities: list[SelectEntity] = [ FlashForgeSelect(coordinator, client, description, printer_name, entry.entry_id) for description in SELECTS ] + entities.append( + FlashForgeFileSelect( + coordinator, file_coordinator, client, entry, printer_name + ) + ) async_add_entities(entities) + entity_platform.async_get_current_platform().async_register_entity_service( + SERVICE_PRINT_FILE, PRINT_FILE_SCHEMA, _async_print_file_service + ) + class FlashForgeSelect(CoordinatorEntity[FlashForgeDataUpdateCoordinator], SelectEntity): """Representation of a FlashForge select entity.""" @@ -131,3 +174,104 @@ async def async_select_option(self, option: str) -> None: self.entity_description.name, err, ) + + +class FlashForgeFileSelect( + CoordinatorEntity[FlashForgeFileListCoordinator], SelectEntity +): + """Lists the files stored on the printer and holds the one picked for printing. + + Selecting an option only records the choice; the print is started by the + "Print Selected File" button or the ``flashforge.print_file`` service. + """ + + _attr_has_entity_name = True + _attr_translation_key = "print_file" + _attr_icon = "mdi:file-document-multiple-outline" + + def __init__( + self, + machine_coordinator: FlashForgeDataUpdateCoordinator, + coordinator: FlashForgeFileListCoordinator, + client: FlashForgeClient, + entry: ConfigEntry, + printer_name: str, + ) -> None: + """Initialize the print file select entity.""" + super().__init__(coordinator) + self._machine_coordinator = machine_coordinator + self._client = client + self._entry = entry + self._attr_unique_id = f"{entry.entry_id}_print_file" + self._attr_device_info = build_device_info( + machine_coordinator, printer_name, entry.entry_id + ) + + @property + def options(self) -> list[str]: + """Return the files currently stored on the printer.""" + return self.coordinator.file_names + + @property + def current_option(self) -> str | None: + """Return the file picked for printing, if it is still on the printer.""" + selected = self.coordinator.selected_file + if selected is None or selected not in self.options: + return None + return selected + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the metadata the printer reports for each file.""" + return { + "files": [ + { + "name": entry.gcode_file_name, + "printing_time": entry.printing_time, + "filament_weight": entry.total_filament_weight, + "tool_count": entry.gcode_tool_cnt, + "uses_material_station": bool(entry.use_matl_station), + } + for entry in self.coordinator.data or [] + ] + } + + async def async_select_option(self, option: str) -> None: + """Record the file to print.""" + if option not in self.options: + raise ServiceValidationError(f"'{option}' is not a file on the printer") + + self.coordinator.selected_file = option + # Also refreshes the print button, whose availability follows the selection. + self.coordinator.async_update_listeners() + + async def async_print_file( + self, + file_name: str | None = None, + leveling_before_print: bool | None = None, + ) -> None: + """Start printing a file that is stored on the printer. + + Defaults to the currently selected file and to the bed-leveling setting + from the integration options. + """ + target = (file_name or self.current_option or "").strip() + if not target: + raise ServiceValidationError( + "No file to print: select a file first or pass a file name" + ) + + if leveling_before_print is None: + leveling_before_print = self._entry.options.get( + CONF_LEVELING_BEFORE_PRINT, DEFAULT_LEVELING_BEFORE_PRINT + ) + + await async_start_local_print( + self._client, + target, + leveling_before_print=bool(leveling_before_print), + file_entry=self.coordinator.entry_for(target), + machine_info=self._machine_coordinator.data, + ) + + await self._machine_coordinator.async_request_refresh() diff --git a/custom_components/flashforge/services.yaml b/custom_components/flashforge/services.yaml new file mode 100644 index 0000000..bb15184 --- /dev/null +++ b/custom_components/flashforge/services.yaml @@ -0,0 +1,15 @@ +print_file: + target: + entity: + integration: flashforge + domain: select + fields: + file_name: + required: false + example: benchy.3mf + selector: + text: + leveling_before_print: + required: false + selector: + boolean: diff --git a/custom_components/flashforge/strings.json b/custom_components/flashforge/strings.json index a68b2c6..68b6ca9 100644 --- a/custom_components/flashforge/strings.json +++ b/custom_components/flashforge/strings.json @@ -74,7 +74,8 @@ "description": "Configure options for your FlashForge printer", "data": { "scan_interval": "Update Interval (seconds)", - "override_led_availability": "Always show LED switch (override printer capability check)" + "override_led_availability": "Always show LED switch (override printer capability check)", + "leveling_before_print": "Level the bed before starting a print" } } } @@ -150,9 +151,11 @@ "pause_print": { "name": "Pause Print" }, "resume_print": { "name": "Resume Print" }, "cancel_print": { "name": "Cancel Print" }, - "clear_status": { "name": "Clear Status" } + "clear_status": { "name": "Clear Status" }, + "print_selected_file": { "name": "Print Selected File" } }, "select": { + "print_file": { "name": "Print File" }, "filtration_mode": { "name": "Filtration Mode", "state": { @@ -172,5 +175,21 @@ "ifs_slot_3": { "name": "Material Station Slot 3" }, "ifs_slot_4": { "name": "Material Station Slot 4" } } + }, + "services": { + "print_file": { + "name": "Print file", + "description": "Starts printing a file that is already stored on the printer.", + "fields": { + "file_name": { + "name": "File name", + "description": "Name of the file on the printer. Defaults to the file selected on the Print File entity." + }, + "leveling_before_print": { + "name": "Level bed before print", + "description": "Run bed leveling before the print starts. Defaults to the integration option." + } + } + } } } diff --git a/custom_components/flashforge/translations/en.json b/custom_components/flashforge/translations/en.json index a68b2c6..68b6ca9 100644 --- a/custom_components/flashforge/translations/en.json +++ b/custom_components/flashforge/translations/en.json @@ -74,7 +74,8 @@ "description": "Configure options for your FlashForge printer", "data": { "scan_interval": "Update Interval (seconds)", - "override_led_availability": "Always show LED switch (override printer capability check)" + "override_led_availability": "Always show LED switch (override printer capability check)", + "leveling_before_print": "Level the bed before starting a print" } } } @@ -150,9 +151,11 @@ "pause_print": { "name": "Pause Print" }, "resume_print": { "name": "Resume Print" }, "cancel_print": { "name": "Cancel Print" }, - "clear_status": { "name": "Clear Status" } + "clear_status": { "name": "Clear Status" }, + "print_selected_file": { "name": "Print Selected File" } }, "select": { + "print_file": { "name": "Print File" }, "filtration_mode": { "name": "Filtration Mode", "state": { @@ -172,5 +175,21 @@ "ifs_slot_3": { "name": "Material Station Slot 3" }, "ifs_slot_4": { "name": "Material Station Slot 4" } } + }, + "services": { + "print_file": { + "name": "Print file", + "description": "Starts printing a file that is already stored on the printer.", + "fields": { + "file_name": { + "name": "File name", + "description": "Name of the file on the printer. Defaults to the file selected on the Print File entity." + }, + "leveling_before_print": { + "name": "Level bed before print", + "description": "Run bed leveling before the print starts. Defaults to the integration option." + } + } + } } } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ec2086f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "flashforge-integration" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = [] diff --git a/requirements-test.txt b/requirements-test.txt index 585855c..c3e539c 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -9,6 +9,9 @@ pytest-cov>=4.1.0 # Snapshot testing syrupy>=4.0.0 +# Schema validation (used by the config flow and the print_file service schema) +voluptuous>=0.13.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 diff --git a/tests/ha_mocks.py b/tests/ha_mocks.py index 3a979b3..578efb3 100644 --- a/tests/ha_mocks.py +++ b/tests/ha_mocks.py @@ -34,7 +34,8 @@ class Entity: """Stub for homeassistant.helpers.entity.Entity.""" - pass + def async_write_ha_state(self) -> None: + """Stub for the state write-back; a no-op outside a running HA.""" class CoordinatorEntity(Entity): @@ -61,6 +62,10 @@ def __init__(self, hass, logger, name: str, update_interval) -> None: self.data = None self.last_update_success = True self.async_request_refresh = AsyncMock() + self.async_update_listeners = MagicMock() + self.async_refresh = AsyncMock() + self.async_config_entry_first_refresh = AsyncMock() + self.async_shutdown = AsyncMock() def __class_getitem__(cls, item): """Make class subscriptable for type hints like DataUpdateCoordinator[Data].""" @@ -71,6 +76,14 @@ class UpdateFailed(Exception): """Stub for homeassistant.helpers.update_coordinator.UpdateFailed.""" +class HomeAssistantError(Exception): + """Stub for homeassistant.exceptions.HomeAssistantError.""" + + +class ServiceValidationError(HomeAssistantError): + """Stub for homeassistant.exceptions.ServiceValidationError.""" + + class ConfigEntryNotReady(Exception): """Stub for homeassistant.exceptions.ConfigEntryNotReady.""" @@ -402,6 +415,8 @@ class OptionsFlow: util_module.dt.utcnow = lambda: datetime.datetime.now(datetime.timezone.utc) sys.modules["homeassistant.util"] = util_module exceptions_module = MagicMock() + exceptions_module.HomeAssistantError = HomeAssistantError + exceptions_module.ServiceValidationError = ServiceValidationError exceptions_module.ConfigEntryNotReady = ConfigEntryNotReady exceptions_module.ConfigEntryAuthFailed = ConfigEntryAuthFailed sys.modules["homeassistant.exceptions"] = exceptions_module diff --git a/tests/unit/test_file_list_coordinator.py b/tests/unit/test_file_list_coordinator.py new file mode 100644 index 0000000..5b79b3c --- /dev/null +++ b/tests/unit/test_file_list_coordinator.py @@ -0,0 +1,100 @@ +"""Unit tests for the local file list coordinator.""" + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, 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.coordinator import FlashForgeFileListCoordinator +from flashforge.models import FFGcodeFileEntry +from homeassistant.helpers.update_coordinator import UpdateFailed + + +def _entry(name: str, printing_time: int = 0) -> FFGcodeFileEntry: + return FFGcodeFileEntry(gcode_file_name=name, printing_time=printing_time) + + +def _coordinator(client: Mock) -> FlashForgeFileListCoordinator: + return FlashForgeFileListCoordinator( + hass=Mock(), client=client, name="Workshop Printer" + ) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_update_returns_files_reported_by_the_printer(): + """The coordinator surfaces the printer's recent file list.""" + client = Mock() + client.files.get_recent_file_list = AsyncMock( + return_value=[_entry("benchy.3mf", 3600), _entry("bracket.gcode", 900)] + ) + coordinator = _coordinator(client) + + data = await coordinator._async_update_data() + + assert [entry.gcode_file_name for entry in data] == ["benchy.3mf", "bracket.gcode"] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_update_drops_entries_without_a_file_name(): + """Entries without a usable file name would break the select options.""" + client = Mock() + client.files.get_recent_file_list = AsyncMock( + return_value=[_entry("benchy.3mf"), _entry("")] + ) + + data = await _coordinator(client)._async_update_data() + + assert [entry.gcode_file_name for entry in data] == ["benchy.3mf"] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_update_handles_a_missing_list(): + """The library returns None/[] when the printer has no files.""" + client = Mock() + client.files.get_recent_file_list = AsyncMock(return_value=None) + + assert await _coordinator(client)._async_update_data() == [] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_update_wraps_errors_in_update_failed(): + """Connection problems must surface as UpdateFailed so HA retries.""" + client = Mock() + client.files.get_recent_file_list = AsyncMock(side_effect=OSError("boom")) + coordinator = _coordinator(client) + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + +@pytest.mark.unit +def test_file_names_and_entry_lookup(): + """Helpers expose the file names and the metadata entry for a file.""" + coordinator = _coordinator(Mock()) + coordinator.data = [_entry("benchy.3mf", 3600), _entry("bracket.gcode", 900)] + + assert coordinator.file_names == ["benchy.3mf", "bracket.gcode"] + assert coordinator.entry_for("bracket.gcode").printing_time == 900 + assert coordinator.entry_for("missing.gcode") is None + + +@pytest.mark.unit +def test_helpers_tolerate_no_data_yet(): + """Before the first successful fetch the coordinator holds no data.""" + coordinator = _coordinator(Mock()) + + assert coordinator.file_names == [] + assert coordinator.entry_for("benchy.3mf") is None + assert coordinator.selected_file is None diff --git a/tests/unit/test_print_file_entities.py b/tests/unit/test_print_file_entities.py new file mode 100644 index 0000000..245cf37 --- /dev/null +++ b/tests/unit/test_print_file_entities.py @@ -0,0 +1,237 @@ +"""Unit tests for the print file select entity and the print button.""" + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch + +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.button import FlashForgePrintSelectedFileButton +from custom_components.flashforge.const import CONF_LEVELING_BEFORE_PRINT +from custom_components.flashforge.coordinator import FlashForgeFileListCoordinator +from custom_components.flashforge.select import FlashForgeFileSelect +from flashforge.models import FFGcodeFileEntry +from homeassistant.exceptions import HomeAssistantError + + +def _entry(name: str, printing_time: int = 3600) -> FFGcodeFileEntry: + return FFGcodeFileEntry( + gcode_file_name=name, + printing_time=printing_time, + total_filament_weight=25.5, + gcode_tool_cnt=1, + use_matl_station=False, + ) + + +def _build(options: dict | None = None, files: list[FFGcodeFileEntry] | None = None): + """Build a select entity, a button, and the pieces they share.""" + file_coordinator = FlashForgeFileListCoordinator( + hass=Mock(), client=Mock(), name="Workshop Printer" + ) + file_coordinator.data = ( + files if files is not None else [_entry("benchy.3mf"), _entry("bracket.gcode")] + ) + + machine_coordinator = Mock(data=Mock(), last_update_success=True) + machine_coordinator.async_request_refresh = AsyncMock() + + config_entry = Mock(entry_id="entry-1", options=options or {}) + client = Mock() + + select = FlashForgeFileSelect( + machine_coordinator, file_coordinator, client, config_entry, "Workshop Printer" + ) + button = FlashForgePrintSelectedFileButton( + machine_coordinator, file_coordinator, client, config_entry, "Workshop Printer" + ) + return select, button, file_coordinator, machine_coordinator, client + + +# --------------------------------------------------------------------------- # +# Select entity +# --------------------------------------------------------------------------- # + + +@pytest.mark.unit +def test_options_list_the_files_on_the_printer(): + """The dropdown shows what the printer reported.""" + select, _, _, _, _ = _build() + + assert select.options == ["benchy.3mf", "bracket.gcode"] + assert select.current_option is None + + +@pytest.mark.unit +def test_file_metadata_is_exposed_as_attributes(): + """Print time and filament weight are available for templates and cards.""" + select, _, _, _, _ = _build() + + files = select.extra_state_attributes["files"] + + assert files[0] == { + "name": "benchy.3mf", + "printing_time": 3600, + "filament_weight": 25.5, + "tool_count": 1, + "uses_material_station": False, + } + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_selecting_a_file_records_it(): + """Selecting only records the choice - it does not start a print.""" + select, button, file_coordinator, _, client = _build() + + await select.async_select_option("bracket.gcode") + + assert file_coordinator.selected_file == "bracket.gcode" + assert select.current_option == "bracket.gcode" + client.job_control.assert_not_called() + # The print button's availability follows the selection, so it has to be + # told about it right away instead of waiting for the next poll. + file_coordinator.async_update_listeners.assert_called_once() + assert button.available is True + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_selecting_an_unknown_file_is_rejected(): + """Guards against stale dashboards picking a deleted file.""" + select, _, _, _, _ = _build() + + with pytest.raises(HomeAssistantError): + await select.async_select_option("gone.gcode") + + +@pytest.mark.unit +def test_selection_is_dropped_when_the_file_disappears(): + """A file deleted on the printer must not stay reported as the state.""" + select, _, file_coordinator, _, _ = _build() + file_coordinator.selected_file = "benchy.3mf" + + file_coordinator.data = [_entry("bracket.gcode")] + + assert select.current_option is None + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_print_file_service_uses_the_selected_file_and_option_default(): + """Called without arguments the service prints the selection.""" + select, _, file_coordinator, machine_coordinator, client = _build( + options={CONF_LEVELING_BEFORE_PRINT: True} + ) + file_coordinator.selected_file = "benchy.3mf" + + with patch( + "custom_components.flashforge.select.async_start_local_print", + new=AsyncMock(), + ) as start: + await select.async_print_file() + + assert start.await_args.args == (client, "benchy.3mf") + assert start.await_args.kwargs["leveling_before_print"] is True + assert start.await_args.kwargs["file_entry"].gcode_file_name == "benchy.3mf" + assert start.await_args.kwargs["machine_info"] is machine_coordinator.data + machine_coordinator.async_request_refresh.assert_awaited_once() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_print_file_service_accepts_a_file_not_in_the_list(): + """The list only holds the printer's recent files, any name may be printed.""" + select, _, _, _, client = _build() + + with patch( + "custom_components.flashforge.select.async_start_local_print", + new=AsyncMock(), + ) as start: + await select.async_print_file("older.gcode", False) + + assert start.await_args.args == (client, "older.gcode") + assert start.await_args.kwargs["leveling_before_print"] is False + assert start.await_args.kwargs["file_entry"] is None + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_print_file_service_without_a_file_errors(): + """Nothing selected and no file name given is a user error.""" + select, _, _, _, _ = _build() + + with pytest.raises(HomeAssistantError): + await select.async_print_file() + + +# --------------------------------------------------------------------------- # +# Button +# --------------------------------------------------------------------------- # + + +@pytest.mark.unit +def test_button_needs_a_selected_file(): + """Without a selection there is nothing to print.""" + _, button, file_coordinator, _, _ = _build() + + assert button.available is False + + file_coordinator.selected_file = "benchy.3mf" + assert button.available is True + + # A selection that is no longer on the printer keeps the button disabled. + file_coordinator.selected_file = "gone.gcode" + assert button.available is False + + +@pytest.mark.unit +def test_button_unavailable_when_the_printer_is_unreachable(): + """Availability follows both coordinators.""" + _, button, file_coordinator, machine_coordinator, _ = _build() + file_coordinator.selected_file = "benchy.3mf" + + machine_coordinator.last_update_success = False + assert button.available is False + + machine_coordinator.last_update_success = True + file_coordinator.last_update_success = False + assert button.available is False + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_button_starts_the_selected_file(): + """Pressing the button prints the selected file and refreshes the state.""" + _, button, file_coordinator, machine_coordinator, client = _build( + options={CONF_LEVELING_BEFORE_PRINT: True} + ) + file_coordinator.selected_file = "bracket.gcode" + + with patch( + "custom_components.flashforge.button.async_start_local_print", + new=AsyncMock(), + ) as start: + await button.async_press() + + assert start.await_args.args == (client, "bracket.gcode") + assert start.await_args.kwargs["leveling_before_print"] is True + assert start.await_args.kwargs["file_entry"].gcode_file_name == "bracket.gcode" + machine_coordinator.async_request_refresh.assert_awaited_once() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_button_without_a_selection_errors(): + """A press without a selection reports a clear error.""" + _, button, _, _, _ = _build() + + with pytest.raises(HomeAssistantError): + await button.async_press() diff --git a/tests/unit/test_print_job.py b/tests/unit/test_print_job.py new file mode 100644 index 0000000..8270e40 --- /dev/null +++ b/tests/unit/test_print_job.py @@ -0,0 +1,271 @@ +"""Unit tests for starting prints of files stored on the printer.""" + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, 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.print_job import ( + async_start_local_print, + build_material_mappings, + needs_material_station, +) +from flashforge.models import FFGcodeFileEntry, FFGcodeToolData +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + + +def _tool( + tool_id: int = 0, + slot_id: int = 1, + material_name: str = "PLA", + material_color: str = "#FF0000", +) -> FFGcodeToolData: + return FFGcodeToolData( + filament_weight=12.5, + material_color=material_color, + material_name=material_name, + slot_id=slot_id, + tool_id=tool_id, + ) + + +def _file_entry( + name: str = "benchy.3mf", + *, + use_matl_station: bool | None = None, + tools: list[FFGcodeToolData] | None = None, +) -> FFGcodeFileEntry: + return FFGcodeFileEntry( + gcode_file_name=name, + printing_time=3600, + use_matl_station=use_matl_station, + gcode_tool_datas=tools, + ) + + +def _machine_info(slots: list[tuple[int, str, str]] | None = None) -> Mock: + """Build a machine info stub whose Material Station reports the given slots.""" + slot_infos = [ + Mock(slot_id=slot_id, material_name=material, material_color=color) + for slot_id, material, color in slots or [] + ] + return Mock(matl_station_info=Mock(slot_infos=slot_infos)) + + +def _client(**flags) -> Mock: + client = Mock(**{"is_creator5": False, "is_ad5x": False, **flags}) + client.job_control.start_creator5_job = AsyncMock(return_value=True) + client.job_control.start_ad5x_multi_color_job = AsyncMock(return_value=True) + client.job_control.start_ad5x_single_color_job = AsyncMock(return_value=True) + client.job_control.print_local_file = AsyncMock(return_value=True) + return client + + +# --------------------------------------------------------------------------- # +# Material Station mapping +# --------------------------------------------------------------------------- # + + +@pytest.mark.unit +def test_material_station_detection(): + """Only files sliced for the Material Station need mappings.""" + assert needs_material_station(None) is False + assert needs_material_station(_file_entry()) is False + assert needs_material_station(_file_entry(use_matl_station=True)) is False + assert ( + needs_material_station(_file_entry(use_matl_station=True, tools=[_tool()])) + is True + ) + # A plain multi-tool file that does not use the station prints as-is. + assert needs_material_station(_file_entry(tools=[_tool()])) is False + + +@pytest.mark.unit +def test_mapping_takes_slot_color_from_the_printer(): + """The slot color must describe the filament actually loaded in that slot.""" + file_entry = _file_entry( + use_matl_station=True, + tools=[_tool(tool_id=0, slot_id=2, material_color="#FF0000")], + ) + machine_info = _machine_info([(2, "PLA", "#00FF00")]) + + mapping = build_material_mappings(file_entry, machine_info)[0] + + assert mapping.tool_id == 0 + assert mapping.slot_id == 2 + assert mapping.material_name == "PLA" + assert mapping.tool_material_color == "#FF0000" + assert mapping.slot_material_color == "#00FF00" + + +@pytest.mark.unit +def test_mapping_falls_back_to_the_known_color(): + """A missing color on one side is filled in from the other.""" + file_entry = _file_entry( + use_matl_station=True, tools=[_tool(slot_id=1, material_color="")] + ) + machine_info = _machine_info([(1, "PETG", "#0000FF")]) + + mapping = build_material_mappings(file_entry, machine_info)[0] + + assert mapping.tool_material_color == "#0000FF" + assert mapping.slot_material_color == "#0000FF" + + # The other way round: the printer reports no slot color. + mapping = build_material_mappings( + _file_entry(use_matl_station=True, tools=[_tool(material_color="#ABCDEF")]), + _machine_info([(1, "PLA", "")]), + )[0] + assert mapping.tool_material_color == "#ABCDEF" + assert mapping.slot_material_color == "#ABCDEF" + + +@pytest.mark.unit +def test_mapping_covers_every_tool(): + """Every tool in the file gets its own mapping.""" + file_entry = _file_entry( + use_matl_station=True, + tools=[ + _tool(tool_id=0, slot_id=1, material_color="#111111"), + _tool(tool_id=1, slot_id=3, material_name="PETG", material_color="#222222"), + ], + ) + machine_info = _machine_info([(1, "PLA", "#111111"), (3, "PETG", "#333333")]) + + mappings = build_material_mappings(file_entry, machine_info) + + assert [(m.tool_id, m.slot_id) for m in mappings] == [(0, 1), (1, 3)] + assert mappings[1].slot_material_color == "#333333" + + +@pytest.mark.unit +@pytest.mark.parametrize( + "tool", + [ + _tool(slot_id=0), # the file has no slot assignment + _tool(material_color=""), # no color anywhere + _tool(material_name=""), # no material name + ], +) +def test_mapping_refuses_incomplete_tool_data(tool): + """Rather than guess, tell the user to start the print from the slicer.""" + file_entry = _file_entry(use_matl_station=True, tools=[tool]) + + # A ServiceValidationError - the file cannot be printed this way, the + # integration itself did not fail. + with pytest.raises(ServiceValidationError): + build_material_mappings(file_entry, _machine_info()) + + +# --------------------------------------------------------------------------- # +# Print start dispatch +# --------------------------------------------------------------------------- # + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_creator5_uses_the_creator5_job_command(): + """The Creator 5 series has its own /printGcode payload shape.""" + client = _client(is_creator5=True) + + await async_start_local_print(client, "benchy.3mf", leveling_before_print=True) + + params = client.job_control.start_creator5_job.await_args.args[0] + assert params.file_name == "benchy.3mf" + assert params.leveling_before_print is True + assert params.material_mappings is None + client.job_control.print_local_file.assert_not_awaited() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_creator5_multi_material_job_sends_mappings(): + """A Material Station file is started with its per-tool mappings.""" + client = _client(is_creator5=True) + file_entry = _file_entry(use_matl_station=True, tools=[_tool(slot_id=2)]) + + await async_start_local_print( + client, + "benchy.3mf", + leveling_before_print=False, + file_entry=file_entry, + machine_info=_machine_info([(2, "PLA", "#00FF00")]), + ) + + params = client.job_control.start_creator5_job.await_args.args[0] + assert len(params.material_mappings) == 1 + assert params.material_mappings[0].slot_id == 2 + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_ad5x_single_and_multi_color_paths(): + """The AD5X uses separate commands for single- and multi-color jobs.""" + client = _client(is_ad5x=True) + + await async_start_local_print(client, "benchy.3mf", leveling_before_print=False) + client.job_control.start_ad5x_single_color_job.assert_awaited_once() + + await async_start_local_print( + client, + "multi.3mf", + leveling_before_print=False, + file_entry=_file_entry(use_matl_station=True, tools=[_tool()]), + machine_info=_machine_info([(1, "PLA", "#00FF00")]), + ) + client.job_control.start_ad5x_multi_color_job.assert_awaited_once() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_other_models_use_print_local_file(): + """The 5M family keeps the generic print command.""" + client = _client() + + await async_start_local_print(client, "benchy.3mf", leveling_before_print=True) + + client.job_control.print_local_file.assert_awaited_once_with("benchy.3mf", True) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_missing_file_name_is_rejected(): + """An empty file name never reaches the printer.""" + client = _client() + + with pytest.raises(ServiceValidationError): + await async_start_local_print(client, " ", leveling_before_print=False) + + client.job_control.print_local_file.assert_not_awaited() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_rejected_print_raises(): + """A printer that declines the job must surface an error to the user.""" + client = _client() + client.job_control.print_local_file = AsyncMock(return_value=False) + + # Not a ServiceValidationError: the call was valid, the printer refused it. + with pytest.raises(HomeAssistantError) as err: + await async_start_local_print(client, "benchy.3mf", leveling_before_print=False) + assert not isinstance(err.value, ServiceValidationError) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_transport_errors_are_wrapped(): + """Library exceptions are translated into HA errors.""" + client = _client() + client.job_control.print_local_file = AsyncMock(side_effect=OSError("no route")) + + with pytest.raises(HomeAssistantError): + await async_start_local_print(client, "benchy.3mf", leveling_before_print=False) diff --git a/tests/unit/test_setup_entry.py b/tests/unit/test_setup_entry.py index a139a58..2a18400 100644 --- a/tests/unit/test_setup_entry.py +++ b/tests/unit/test_setup_entry.py @@ -58,12 +58,19 @@ async def test_async_setup_entry_uses_library_led_override_option(): coordinator = Mock() coordinator.async_config_entry_first_refresh = AsyncMock() + file_coordinator = Mock() + file_coordinator.async_refresh = AsyncMock() + options_sentinel = object() with ( patch("custom_components.flashforge.FiveMClientConnectionOptions", return_value=options_sentinel) as options_cls, patch("custom_components.flashforge.FlashForgeClient", return_value=client) as client_cls, patch("custom_components.flashforge.FlashForgeDataUpdateCoordinator", return_value=coordinator), + patch( + "custom_components.flashforge.FlashForgeFileListCoordinator", + return_value=file_coordinator, + ), ): result = await async_setup_entry(hass, entry) @@ -81,3 +88,9 @@ async def test_async_setup_entry_uses_library_led_override_option(): 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 + # The file list is fetched with the non-raising refresh so a file list + # hiccup cannot block setup. + file_coordinator.async_refresh.assert_awaited_once() + assert ( + hass.data[DOMAIN][entry.entry_id]["file_coordinator"] is file_coordinator + ) From 999fd5adc27d20327f3cac6ff9a304c045e5d8d7 Mon Sep 17 00:00:00 2001 From: RedAces Date: Sat, 25 Jul 2026 20:58:18 +0200 Subject: [PATCH 02/10] Add test script to probe the file list --- scripts/file_print_probe.py | 260 ++++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 scripts/file_print_probe.py diff --git a/scripts/file_print_probe.py b/scripts/file_print_probe.py new file mode 100644 index 0000000..99d8fe6 --- /dev/null +++ b/scripts/file_print_probe.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Probe the local-file-list / print-start feature against a real printer. + +Runs the integration's own code paths (``print_job.build_material_mappings``, +``print_job.async_start_local_print``) without a Home Assistant runtime, so the +HTTP requests, the parsed file list, and the derived Material Station mappings +can be verified on real hardware from Windows. + +Read-only unless ``--print`` is passed. + +Usage (from the repository root): + + python scripts/file_print_probe.py --discover + python scripts/file_print_probe.py --ip 192.168.1.50 --serial SN123 --check-code ABCD + python scripts/file_print_probe.py --ip ... --raw + python scripts/file_print_probe.py --ip ... --print benchy.3mf --yes + +Credentials may also come from the environment: FF_IP, FF_SERIAL, FF_CHECK_CODE. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +# The integration modules import Home Assistant; borrow the test mocks so they +# can be imported outside a HA runtime. +from tests.ha_mocks import mock_homeassistant # noqa: E402 + +mock_homeassistant() + +from flashforge import ( # noqa: E402 + FiveMClientConnectionOptions, + FlashForgeClient, + PrinterDiscovery, +) +from flashforge.api.constants.endpoints import Endpoints # noqa: E402 +from flashforge.models.responses import GCodeListResponse # noqa: E402 +from pydantic import ValidationError # noqa: E402 + +from custom_components.flashforge.print_job import ( # noqa: E402 + async_start_local_print, + build_material_mappings, + needs_material_station, +) +from homeassistant.exceptions import HomeAssistantError # noqa: E402 + + +def _fmt_duration(seconds: int | None) -> str: + if not seconds: + return "-" + hours, rest = divmod(int(seconds), 3600) + return f"{hours}h{rest // 60:02d}m" + + +async def discover() -> None: + """List the printers answering the UDP discovery broadcast.""" + printers = await PrinterDiscovery().discover() + if not printers: + print("No printers answered the discovery broadcast.") + return + for printer in printers: + print( + f" {printer.name or ''} serial={printer.serial_number} " + f"ip={printer.ip_address} model={getattr(printer, 'model', '?')}" + ) + print("\nThe check code is only on the printer's LAN mode screen.") + + +async def connect(ip: str, serial: str, check_code: str) -> FlashForgeClient: + """Set the client up the same way the integration's async_setup_entry does.""" + client = FlashForgeClient( + ip_address=ip, + serial_number=serial, + check_code=check_code, + options=FiveMClientConnectionOptions(), + ) + info = await client.info.get() + if info is None: + raise SystemExit(f"No answer from {ip} - check IP, serial, and check code.") + client.cache_details(info) + if not await client.send_product_command(): + raise SystemExit("Printer rejected the credentials (check code).") + return client + + +def report_printer(client: FlashForgeClient, info) -> None: + print("Printer") + print(f" model {getattr(info, 'model', None)} (pid={getattr(info, 'pid', None)})") + print(f" is_creator5 {client.is_creator5}") + print(f" is_creator5_pro {client.is_creator5_pro}") + print(f" is_ad5x {client.is_ad5x}") + print(f" http_only {client.http_only}") + print(f" has_matl_station {getattr(info, 'has_matl_station', None)}") + print(f" state {getattr(info, 'machine_state', None)}") + + station = getattr(info, "matl_station_info", None) + for slot in getattr(station, "slot_infos", None) or []: + print( + f" slot {getattr(slot, 'slot_id', '?')}: " + f"material={getattr(slot, 'material_name', '') or '-'} " + f"color={getattr(slot, 'material_color', '') or '-'} " + f"loaded={getattr(slot, 'has_filament', None)}" + ) + + # Which print-start command async_start_local_print would pick. + if client.is_creator5: + route = "job_control.start_creator5_job" + elif client.is_ad5x: + route = "job_control.start_ad5x_{single,multi}_color_job" + else: + route = "job_control.print_local_file" + print(f" print command {route}") + + +def report_files(entries, info) -> None: + print(f"\nFiles reported by /gcodeList ({len(entries)})") + if not entries: + print(" - upload a file to the printer first.") + return + + for entry in entries: + print(f"\n {entry.gcode_file_name}") + print( + f" print time {_fmt_duration(entry.printing_time)} " + f"filament {entry.total_filament_weight or '-'} g " + f"tools {entry.gcode_tool_cnt if entry.gcode_tool_cnt is not None else '-'} " + f"material station {bool(entry.use_matl_station)}" + ) + for tool in entry.gcode_tool_datas or []: + print( + f" tool {tool.tool_id} -> slot {tool.slot_id} " + f"{tool.material_name or '-'} {tool.material_color or '-'} " + f"{tool.filament_weight} g" + ) + + if not needs_material_station(entry): + print(" mapping: none needed (single-material start)") + continue + try: + mappings = build_material_mappings(entry, info) + except HomeAssistantError as err: + print(f" mapping REFUSED: {err}") + continue + for mapping in mappings: + print( + f" mapping: tool {mapping.tool_id} -> slot {mapping.slot_id} " + f"{mapping.material_name} tool={mapping.tool_material_color} " + f"slot={mapping.slot_material_color}" + ) + + +async def report_raw(client: FlashForgeClient) -> None: + """Dump the untouched /gcodeList payload and how the library models parse it. + + Both GCodeListResponse and FFGcodeFileEntry are ``extra="forbid"``: a single + unexpected field makes the library fall back to a names-only list and drop + all per-file metadata. This shows whether that is happening. + """ + payload = { + "serialNumber": client.serial_number, + "checkCode": client.check_code, + } + session = await client.get_http_session() + async with session.post( + client.get_endpoint(Endpoints.GCODE_LIST), + json=payload, + headers={"Content-Type": "application/json"}, + ) as response: + print(f"\nRaw POST {Endpoints.GCODE_LIST} -> HTTP {response.status}") + data = await response.json(content_type=None) + + print(json.dumps(data, indent=2, ensure_ascii=False)[:4000]) + + print("\nParsing with the library's GCodeListResponse model:") + try: + result = GCodeListResponse(**data) + except ValidationError as err: + print(" REJECTED - the library falls back to names only. Reasons:") + for error in err.errors(): + print(f" {'.'.join(str(p) for p in error['loc'])}: {error['msg']}") + return + detail = result.gcode_list_detail + print(f" accepted. gcodeListDetail entries: {len(detail) if detail else 0}") + if not detail: + print(" -> the printer itself reports no per-file metadata.") + + +async def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--discover", action="store_true", help="only run discovery") + parser.add_argument("--ip", default=os.environ.get("FF_IP")) + parser.add_argument("--serial", default=os.environ.get("FF_SERIAL")) + parser.add_argument("--check-code", default=os.environ.get("FF_CHECK_CODE")) + parser.add_argument( + "--raw", + action="store_true", + help="dump the untouched /gcodeList payload and the model parse result", + ) + parser.add_argument("--print", dest="print_file", help="START a print of this file") + parser.add_argument("--leveling", action="store_true", help="level the bed first") + parser.add_argument("--yes", action="store_true", help="confirm the print start") + args = parser.parse_args() + + if args.discover: + await discover() + return + + if not (args.ip and args.serial and args.check_code): + parser.error("--ip, --serial and --check-code are required (or FF_* env vars)") + + client = await connect(args.ip, args.serial, args.check_code) + try: + info = await client.info.get() + client.cache_details(info) + report_printer(client, info) + + entries = await client.files.get_recent_file_list() + entries = [e for e in entries or [] if e.gcode_file_name] + report_files(entries, info) + + if args.raw: + await report_raw(client) + + if not args.print_file: + print("\nRead-only run. Pass --print --yes to start a print.") + return + + if not args.yes: + print( + f"\nWould start '{args.print_file}' " + f"(leveling={args.leveling}). Re-run with --yes to actually print." + ) + return + + entry = next( + (e for e in entries if e.gcode_file_name == args.print_file), None + ) + print(f"\nStarting '{args.print_file}' (leveling={args.leveling}) ...") + await async_start_local_print( + client, + args.print_file, + leveling_before_print=args.leveling, + file_entry=entry, + machine_info=info, + ) + print("Printer accepted the job.") + finally: + await client.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) From d1f977c9835328cc297a0f931f0d8bfcd590ae9a Mon Sep 17 00:00:00 2001 From: RedAces Date: Sat, 25 Jul 2026 21:17:32 +0200 Subject: [PATCH 03/10] Fix Material Station detection and unknown file metadata Two fixes found by probing a real Creator 5 Pro (pid 41). Material Station entities never appeared on the Creator 5 series. The four slot swatches and the Active Material Station Slot sensor were gated on FFMachineInfo.has_matl_station, a verbatim copy of the raw hasMatlStation field from /detail. A Creator 5 Pro omits that field entirely - it parses as None - while reporting matlStationInfo with four loaded slots, so the gate never opened and the v1.3.0 move off is_ad5x had no effect on this model. Capability detection now lives in util.has_material_station(), which also accepts populated slot data (slotCnt / slotInfos) as proof of the station. Those entities are no longer decided once at setup either: platform setup can run before the station reports in, or after a failed first refresh, which left the printer permanently without them. The slot images and every availability_fn-gated sensor are now added when the capability first shows up on a later coordinator update. The print file select reported unknown metadata as 0 / false. /gcodeList carries per-file detail (gcodeListDetail) only on the AD5X; the Creator 5 returns plain file names, so printing_time stays 0 and every other field None. Running that through bool() claimed "single material" for a file we know nothing about - a three-color file showed up as single-color. file_attributes() now omits what the printer did not report, so absent stays absent. scripts/file_print_probe.py gained the matching diagnostics: --raw dumps the untouched /detail and /gcodeList payloads, --thumb checks whether /gcodeThumb serves a preview for an arbitrary stored file, and the file listing distinguishes "no metadata reported" from "the printer says no material station". --- AGENTS.md | 2 + CHANGELOG.md | 9 +- CLAUDE.md | 10 +- README.md | 20 +- custom_components/flashforge/image.py | 39 +++- custom_components/flashforge/print_job.py | 13 +- custom_components/flashforge/select.py | 33 ++- custom_components/flashforge/sensor.py | 42 +++- custom_components/flashforge/util.py | 24 +- scripts/file_print_probe.py | 121 ++++++++++- tests/ha_mocks.py | 21 ++ tests/unit/test_material_station_gating.py | 241 +++++++++++++++++++++ tests/unit/test_print_file_entities.py | 13 ++ tests/unit/test_print_job.py | 6 + 14 files changed, 543 insertions(+), 51 deletions(-) create mode 100644 tests/unit/test_material_station_gating.py diff --git a/AGENTS.md b/AGENTS.md index eac95d0..0785607 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ Working agreement for AI assistants contributing to `ff-5mp-hass`. - Keep imports ordered and comments purposeful. - 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 gate a capability on a single raw `/detail` field.** Some `FFMachineInfo` fields are verbatim copies of the printer's JSON and are absent on models that clearly have the feature: a Creator 5 Pro omits `hasMatlStation` while reporting four loaded slots in `matlStationInfo`, so `has_matl_station` parses as `None` and the Material Station entities never appeared. Use `util.has_material_station()` (flag OR populated slot data), and confirm against `scripts/file_print_probe.py --raw`, which dumps the untouched `/detail` and `/gcodeList` payloads. +- **Conditional entities must tolerate a late capability.** Platform setup runs once; filtering on `coordinator.data` there drops entities forever if the first refresh failed or the capability reported in late. `sensor.py` / `image.py` re-check on `coordinator.async_add_listener` and add the entities when the gate first passes — follow that pattern. - **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`. ## Standard Workflows diff --git a/CHANGELOG.md b/CHANGELOG.md index 0298d89..dcc890e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Local file list and print start.** The files stored on the printer are now visible in Home Assistant and a print can be started from them: - - `select._print_file` lists the printer's files (from the HTTP `/gcodeList` endpoint) and records which one to print. Each file's print time, filament weight, tool count, and Material Station flag are exposed via `extra_state_attributes["files"]` for cards and templates. + - `select._print_file` lists the printer's files (from the HTTP `/gcodeList` endpoint) and records which one to print. Whatever per-file metadata the printer reports — print time, filament weight, tool count, Material Station flag — is exposed via `extra_state_attributes["files"]` for cards and templates. Values the printer does not report are omitted rather than reported as `0`/`false`. - `button._print_selected_file` starts the selected file. It stays unavailable while no file is selected. - `flashforge.print_file` service (targets the print file select entity) with optional `file_name` and `leveling_before_print` fields, so automations can start any file on the printer — including ones outside the reported list. - New option **"Level the bed before starting a print"** (default off) supplies the default for the button and the service. - - Material Station files (AD5X / Creator 5 series) are started with the per-tool mappings derived from the file's own tool data plus the colors the printer reports for the loaded slots. When that data is incomplete the print is refused with an error telling the user to start it from the slicer instead of guessing a mapping. + - Material Station files are started with the per-tool mappings derived from the file's own tool data plus the colors the printer reports for the loaded slots. When that data is present but incomplete the print is refused with an error telling the user to start it from the slicer instead of guessing a mapping. - The file list is polled by its own coordinator every 60 s (independent of the machine-state interval) and is included in diagnostics. +### 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 is a straight copy of the raw `hasMatlStation` field from `/detail`. A Creator 5 Pro does not report that field at all — verified against real hardware (pid 41, firmware 1.9.4): the `hasMatlStation` key is absent from `/detail` entirely, under any name, while `matlStationInfo` reports `slotCnt: 4` and four loaded slots. The flag therefore parsed as `None`, the entities were never created, and the v1.3.0 change that moved the gate off `is_ad5x` had no effect. Capability detection now lives in `util.has_material_station()`, which accepts populated slot data (`slotCnt` / `slotInfos`) as proof of the station, the same way the library's own AD5X heuristic does. +- **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 the capability first shows up on a later refresh, so a station that reports in after the first poll — or a first refresh that failed outright — no longer leaves the printer permanently without those entities. + ### Notes - The printer's HTTP API only reports its most recent files (10 on current firmware); older files can still be printed by passing `file_name` to `flashforge.print_file`. The TCP full-directory listing is deliberately not used — this integration stays HTTP-only. +- Per-file metadata depends on the model: the AD5X returns `gcodeListDetail` with print time, filament weight, and per-tool material data, while the Creator 5 series (verified on a Creator 5 Pro, firmware PID 41) returns plain file names. On those printers multi-material files are therefore sent without mappings and the printer uses the tool/slot assignment stored in the file. `scripts/file_print_probe.py` dumps what a given printer actually reports. ## [1.3.1] - 2026-07-23 diff --git a/CLAUDE.md b/CLAUDE.md index a87fc60..fb199c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,12 +84,12 @@ Treat this file as the living source of truth for workflows and expectations—u - `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) and the print file select (`FlashForgeFileSelect`, options = the printer's file list, metadata in `extra_state_attributes`). Also registers the `flashforge.print_file` entity service. - `button.py` – Pause / resume / cancel / clear-status commands plus `FlashForgePrintSelectedFileButton`; request a refresh after each action. -- `print_job.py` – Per-model dispatch for starting a file already on the printer (`start_creator5_job` / AD5X single+multi color / `print_local_file`) and `build_material_mappings()`, which derives Material Station mappings from the file's tool data plus the printer's slot colors. Raises `HomeAssistantError` instead of guessing an incomplete mapping. +- `print_job.py` – Per-model dispatch for starting a file already on the printer (`start_creator5_job` / AD5X single+multi color / `print_local_file`) and `build_material_mappings()`, which derives Material Station mappings from the file's tool data plus the printer's slot colors. Raises `ServiceValidationError` instead of guessing an incomplete mapping. **Per-file metadata is model-dependent**: the AD5X returns `gcodeListDetail` (print time, weight, per-tool material data), a Creator 5 Pro returns plain file names — verified on hardware with `scripts/file_print_probe.py`. Unknown values must stay unknown (`select.file_attributes()` omits them); treating them as `0`/`False` would make a multi-material file look single-material. - `services.yaml` – Service definition for `flashforge.print_file` (keep in sync with the `services` block in `strings.json`). - `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. - `diagnostics.py` – HA diagnostics download payload, with `check_code`, `serial_number`, MAC/IP, and cloud registration codes redacted. -- `util.py` – Shared helpers: `async_close_flashforge_client()` for HTTP session disposal, `build_device_info()` for the per-platform device-info dict. +- `util.py` – Shared helpers: `async_close_flashforge_client()` for HTTP session disposal, `build_device_info()` for the per-platform device-info dict, `has_material_station()` for Material Station capability detection (see the guard rails — the raw `has_matl_station` flag is unreliable). - `strings.json` / `translations/en.json` – Keep UI copy synchronized between minimal strings and translation files. Every entity carries a `translation_key`; `name`s never set manually on entities. ## External Dependencies & Linked Projects @@ -286,7 +286,7 @@ pytest tests/unit/ --cov=custom_components.flashforge --cov-report=term-missing pytest tests/unit/test_sensor_value_functions.py -v ``` -**Current coverage (147 tests total):** +**Current coverage (161 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 @@ -301,6 +301,7 @@ pytest tests/unit/test_sensor_value_functions.py -v - `tests/unit/test_file_list_coordinator.py` – local file list fetch, filtering, and error paths - `tests/unit/test_print_job.py` – per-model print-start dispatch and Material Station mapping - `tests/unit/test_print_file_entities.py` – print file select + print button behavior +- `tests/unit/test_material_station_gating.py` – Material Station capability detection (Creator 5 Pro reports no `hasMatlStation` flag) and deferred entity creation for image + sensor platforms **Test dependencies** (`requirements-test.txt`): - Core: `pytest`, `pytest-asyncio`, `pytest-cov` @@ -360,6 +361,7 @@ pytest tests/unit/test_sensor_value_functions.py -v ### Testing Utilities - **Discovery diagnostics** – `scripts/test_discovery.py` and `scripts/discovery_probe.py` help debug LAN communication without HA. +- **File list / print start** – `scripts/file_print_probe.py` runs the integration's own `print_job` code against a real printer without a HA runtime (it borrows `tests/ha_mocks.py`). Read-only by default; `--raw` dumps the untouched `/gcodeList` payload and how the library's pydantic models parse it; `--print --yes` actually starts a print. - **Hardware caveat** – Full verification requires a FlashForge printer with LAN mode enabled; simulated runs only confirm flow logic. ## Implementation Guard Rails @@ -369,6 +371,8 @@ pytest tests/unit/test_sensor_value_functions.py -v - `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. - 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. +- **Capability flags: never trust a raw `/detail` field on its own** – Several `FFMachineInfo` fields are straight copies of the printer's JSON and are simply absent on some models. `has_matl_station` is the known case: a Creator 5 Pro (pid 41) leaves `hasMatlStation` out of `/detail` entirely (it parses as `None`) while `matlStationInfo` reports four loaded slots, so gating on the flag hid the Material Station entities on exactly the models v1.3.0 added them for. Gate on `util.has_material_station()`, which also accepts populated slot data as proof. When adding a capability gate, derive it from the data the capability actually produces, and verify with `scripts/file_print_probe.py --raw` (dumps the untouched `/detail` and `/gcodeList` payloads) before trusting a single field. +- **Capability-gated entities must survive a late capability** – Platforms are only set up once, so deciding availability from `coordinator.data` at setup time permanently drops entities when the first refresh failed or the capability reported in late. `sensor.py` and `image.py` re-check on coordinator updates via `coordinator.async_add_listener` and add the entities when the gate first passes; follow that pattern for new conditional entities instead of filtering once in `async_setup_entry`. - **Error handling** – Wrap connection issues in `ConfigEntryNotReady`, `ConnectionError`, or `UpdateFailed` so Home Assistant retries gracefully. - **Entity additions** - Add to the appropriate entity tuple. diff --git a/README.md b/README.md index e3f2a95..71502ff 100644 --- a/README.md +++ b/README.md @@ -321,22 +321,30 @@ data: leveling_before_print: true # optional, defaults to the integration option ``` -Each file's metadata is available for cards and templates: +Whatever metadata the printer reports per file is available for cards and templates: ```yaml {{ state_attr('select.flashforge_print_file', 'files') }} +# AD5X (reports gcodeListDetail): # [{'name': 'benchy.3mf', 'printing_time': 3600, 'filament_weight': 25.5, # 'tool_count': 1, 'uses_material_station': False}, ...] +# Creator 5 / 5 Pro (reports file names only): +# [{'name': 'benchy.3mf'}, ...] ``` +Keys the printer does not report are omitted rather than reported as `0`/`false`. +The Creator 5 series returns plain file names on `/gcodeList`, so print time, +filament weight, and tool count are simply not available there. + **Bed leveling** before a print is off by default and can be enabled in the integration's **Configure** dialog, or per call via `leveling_before_print`. -**Material Station files** (AD5X / Creator 5 series) are started with the per-tool material -mappings derived from the file's own tool data combined with the colors the printer reports -for the loaded slots. If that information is incomplete, the print is refused with an error -rather than mapping materials by guesswork — start such a print from your slicer or the -FlashForge app instead. +**Material Station files** are started with per-tool material mappings derived from the +file's own tool data combined with the colors the printer reports for the loaded slots — +but only on printers that report that tool data (the AD5X). If the data is present but +incomplete, the print is refused with an error rather than mapping materials by guesswork. +The Creator 5 series reports no per-file tool data at all, so multi-material files are sent +without mappings and the printer uses the tool/slot assignment stored in the file itself. The file list is refreshed every 60 seconds. To refresh it immediately (e.g. right after an upload), call `homeassistant.update_entity` on `select.flashforge_print_file`. diff --git a/custom_components/flashforge/image.py b/custom_components/flashforge/image.py index 72f1a89..3ed07a9 100644 --- a/custom_components/flashforge/image.py +++ b/custom_components/flashforge/image.py @@ -10,14 +10,14 @@ 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 from .const import DOMAIN from .coordinator import FlashForgeDataUpdateCoordinator -from .util import build_device_info +from .util import build_device_info, has_material_station _LOGGER = logging.getLogger(__name__) @@ -45,17 +45,35 @@ 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. + + The station is not always visible on the first refresh (a poll can land + before the station reports in, and the first refresh may have failed + outright), so keep watching the coordinator instead of deciding once at + setup and leaving a Creator 5 / AD5X permanently without the entities. + """ + nonlocal slots_added + if slots_added or not has_material_station(coordinator.data): + 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 +264,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 not has_material_station(data): return None station = getattr(data, "matl_station_info", None) if station is None: @@ -273,8 +291,7 @@ def _swatch_key(self) -> tuple[str, str]: 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 has_material_station(self.coordinator.data) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/custom_components/flashforge/print_job.py b/custom_components/flashforge/print_job.py index 5e85824..22a5a0f 100644 --- a/custom_components/flashforge/print_job.py +++ b/custom_components/flashforge/print_job.py @@ -43,7 +43,14 @@ def _station_slot(machine_info: FFMachineInfo | None, slot_id: int) -> Any | Non def needs_material_station(file_entry: FFGcodeFileEntry | None) -> bool: - """Return True when the file was sliced for the Material Station.""" + """Return True when the file was sliced for the Material Station. + + This can only be answered for printers whose ``/gcodeList`` carries the + per-file detail (``gcodeListDetail``, e.g. the AD5X). The Creator 5 series + returns plain file names, so ``use_matl_station`` and the tool data are + unknown and this returns False - the file is then started without mappings + and the printer falls back to the tool/slot assignment stored in the file. + """ return bool( file_entry is not None and getattr(file_entry, "use_matl_station", False) @@ -115,7 +122,9 @@ async def async_start_local_print( ``file_entry`` is the printer's file list entry for ``file_name``, when known; it is what tells us whether the file needs Material Station mappings. Without - it the file is started as a single-material print. + it - and on printers that report file names only, such as the Creator 5 + series - the file is started without mappings, leaving the printer to use the + tool/slot assignment stored in the file itself. Raises: ServiceValidationError: If no file was given or the mappings cannot be diff --git a/custom_components/flashforge/select.py b/custom_components/flashforge/select.py index 8a9d50f..f13d2d9 100644 --- a/custom_components/flashforge/select.py +++ b/custom_components/flashforge/select.py @@ -7,7 +7,7 @@ from typing import Any from flashforge import FlashForgeClient -from flashforge.models import FFMachineInfo +from flashforge.models import FFGcodeFileEntry, FFMachineInfo import voluptuous as vol from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -39,6 +39,26 @@ } +def file_attributes(entry: FFGcodeFileEntry) -> dict[str, Any]: + """Describe one file, reporting only what the printer actually told us. + + ``/gcodeList`` returns per-file metadata (``gcodeListDetail``) on the AD5X, + but plain file names on the Creator 5 series. Absent values are left out + rather than reported as 0 / False, so a multi-material file on a printer + that reports no metadata is not mistaken for a single-material one. + """ + attributes: dict[str, Any] = {"name": entry.gcode_file_name} + if entry.printing_time: + attributes["printing_time"] = entry.printing_time + if entry.total_filament_weight is not None: + attributes["filament_weight"] = entry.total_filament_weight + if entry.gcode_tool_cnt is not None: + attributes["tool_count"] = entry.gcode_tool_cnt + if entry.use_matl_station is not None: + attributes["uses_material_station"] = entry.use_matl_station + return attributes + + @dataclass class FlashForgeSelectEntityDescription(SelectEntityDescription): """Describes FlashForge select entity.""" @@ -224,16 +244,7 @@ def current_option(self) -> str | None: def extra_state_attributes(self) -> dict[str, Any]: """Return the metadata the printer reports for each file.""" return { - "files": [ - { - "name": entry.gcode_file_name, - "printing_time": entry.printing_time, - "filament_weight": entry.total_filament_weight, - "tool_count": entry.gcode_tool_cnt, - "uses_material_station": bool(entry.use_matl_station), - } - for entry in self.coordinator.data or [] - ] + "files": [file_attributes(entry) for entry in self.coordinator.data or []] } async def async_select_option(self, option: str) -> None: diff --git a/custom_components/flashforge/sensor.py b/custom_components/flashforge/sensor.py index edcaf7e..acb62f2 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 @@ -34,7 +34,7 @@ from .const import DOMAIN from .coordinator import FlashForgeDataUpdateCoordinator -from .util import build_device_info +from .util import build_device_info, has_material_station _LOGGER = logging.getLogger(__name__) @@ -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 has_material_station(data): 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=has_material_station, ), FlashForgeSensorEntityDescription( key="lifetime_filament", @@ -418,15 +418,41 @@ 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_gated_sensors() -> None: + """Add capability-gated sensors as soon as the printer reports them. + + Model-identity gates settle on the first refresh, but the Material + Station gate does not: a poll can land before the station reports in, + and the first refresh may have failed outright. Re-check on every update + so a late-reporting capability still gets its entity. + """ + data = coordinator.data + if data is None: + return + ready = [d for d in pending if d.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_gated_sensors() + if pending: + entry.async_on_unload(coordinator.async_add_listener(_async_add_gated_sensors)) class FlashForgeSensor(CoordinatorEntity[FlashForgeDataUpdateCoordinator], SensorEntity): diff --git a/custom_components/flashforge/util.py b/custom_components/flashforge/util.py index 7fb1307..1f859de 100644 --- a/custom_components/flashforge/util.py +++ b/custom_components/flashforge/util.py @@ -1,7 +1,7 @@ """Utility helpers for the FlashForge integration.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from homeassistant.helpers.device_registry import DeviceInfo @@ -13,6 +13,28 @@ from .coordinator import FlashForgeDataUpdateCoordinator +def has_material_station(data: Any) -> bool: + """Return True when the printer has a Material Station attached. + + ``FFMachineInfo.has_matl_station`` mirrors the raw ``hasMatlStation`` field + from ``/detail``, which the Creator 5 series does not report at all — it + arrives as ``None`` even when ``matlStationInfo`` is fully populated with + four loaded slots. Treat populated slot data as proof of the station, the + same way the library's own AD5X heuristic does, so the flag being absent + does not hide the entities on a Creator 5 / Creator 5 Pro. + """ + if data is None: + return False + if getattr(data, "has_matl_station", None) is True: + return True + station = getattr(data, "matl_station_info", None) + if station is None: + return False + if (getattr(station, "slot_cnt", 0) or 0) > 0: + return True + return bool(getattr(station, "slot_infos", None)) + + async def async_close_flashforge_client(client: "FlashForgeClient") -> None: """Close any HTTP resources held by the FlashForge client without touching TCP.""" session = getattr(client, "_http_session", None) diff --git a/scripts/file_print_probe.py b/scripts/file_print_probe.py index 99d8fe6..74393fc 100644 --- a/scripts/file_print_probe.py +++ b/scripts/file_print_probe.py @@ -42,7 +42,7 @@ PrinterDiscovery, ) from flashforge.api.constants.endpoints import Endpoints # noqa: E402 -from flashforge.models.responses import GCodeListResponse # noqa: E402 +from flashforge.models.responses import FFPrinterDetail, GCodeListResponse # noqa: E402 from pydantic import ValidationError # noqa: E402 from custom_components.flashforge.print_job import ( # noqa: E402 @@ -50,6 +50,7 @@ build_material_mappings, needs_material_station, ) +from custom_components.flashforge.util import has_material_station # noqa: E402 from homeassistant.exceptions import HomeAssistantError # noqa: E402 @@ -98,7 +99,8 @@ def report_printer(client: FlashForgeClient, info) -> None: print(f" is_creator5_pro {client.is_creator5_pro}") print(f" is_ad5x {client.is_ad5x}") print(f" http_only {client.http_only}") - print(f" has_matl_station {getattr(info, 'has_matl_station', None)}") + print(f" has_matl_station {getattr(info, 'has_matl_station', None)} (raw flag)") + print(f" material station {has_material_station(info)} (what the integration gates on)") print(f" state {getattr(info, 'machine_state', None)}") station = getattr(info, "matl_station_info", None) @@ -120,6 +122,17 @@ def report_printer(client: FlashForgeClient, info) -> None: print(f" print command {route}") +def _has_metadata(entry) -> bool: + """Return True when the printer reported anything beyond the file name.""" + return bool( + entry.printing_time + or entry.total_filament_weight is not None + or entry.gcode_tool_cnt is not None + or entry.use_matl_station is not None + or entry.gcode_tool_datas + ) + + def report_files(entries, info) -> None: print(f"\nFiles reported by /gcodeList ({len(entries)})") if not entries: @@ -128,11 +141,24 @@ def report_files(entries, info) -> None: for entry in entries: print(f"\n {entry.gcode_file_name}") + + # Distinguish "the printer says no" from "the printer says nothing": + # a names-only /gcodeList leaves every detail field unset, and calling + # that a single-material file would be a claim we cannot make. + if not _has_metadata(entry): + print(" no metadata reported for this file (names-only /gcodeList)") + print( + " mapping: unknown - the file is started without mappings and " + "the printer uses the tool/slot assignment stored in it" + ) + continue + print( f" print time {_fmt_duration(entry.printing_time)} " - f"filament {entry.total_filament_weight or '-'} g " - f"tools {entry.gcode_tool_cnt if entry.gcode_tool_cnt is not None else '-'} " - f"material station {bool(entry.use_matl_station)}" + f"filament {entry.total_filament_weight if entry.total_filament_weight is not None else '?'} g " + f"tools {entry.gcode_tool_cnt if entry.gcode_tool_cnt is not None else '?'} " + f"material station " + f"{entry.use_matl_station if entry.use_matl_station is not None else '?'}" ) for tool in entry.gcode_tool_datas or []: print( @@ -142,7 +168,7 @@ def report_files(entries, info) -> None: ) if not needs_material_station(entry): - print(" mapping: none needed (single-material start)") + print(" mapping: not needed (file does not use the material station)") continue try: mappings = build_material_mappings(entry, info) @@ -157,6 +183,77 @@ def report_files(entries, info) -> None: ) +async def report_raw_detail(client: FlashForgeClient) -> None: + """Dump the untouched /detail payload and inspect the Material Station keys. + + ``FFMachineInfo.has_matl_station`` is a straight copy of the raw + ``hasMatlStation`` field. The Creator 5 series leaves it None while + ``matlStationInfo`` is fully populated, so this shows whether the printer + omits the flag entirely or reports it under a different name. + """ + payload = { + "serialNumber": client.serial_number, + "checkCode": client.check_code, + } + session = await client.get_http_session() + async with session.post( + client.get_endpoint(Endpoints.DETAIL), + json=payload, + headers={"Content-Type": "application/json"}, + ) as response: + print(f"\nRaw POST {Endpoints.DETAIL} -> HTTP {response.status}") + data = await response.json(content_type=None) + + print(json.dumps(data, indent=2, ensure_ascii=False)[:6000]) + + detail = data.get("detail") if isinstance(data, dict) else None + if not isinstance(detail, dict): + print("\n No 'detail' object in the response.") + return + + print("\nMaterial Station keys in the raw /detail payload:") + print(f" 'hasMatlStation' present {'hasMatlStation' in detail}") + if "hasMatlStation" in detail: + print(f" hasMatlStation {detail['hasMatlStation']!r}") + print(f" 'matlStationInfo' present {'matlStationInfo' in detail}") + station = detail.get("matlStationInfo") + if isinstance(station, dict): + print(f" slotCnt {station.get('slotCnt')!r}") + print(f" slotInfos entries {len(station.get('slotInfos') or [])}") + + # Any key containing "matl"/"station" the model doesn't declare - would + # reveal the flag hiding behind a different name on this firmware. + known = set(FFPrinterDetail.model_fields) | { + field.alias for field in FFPrinterDetail.model_fields.values() if field.alias + } + extras = sorted( + key + for key in detail + if key not in known and ("matl" in key.lower() or "station" in key.lower()) + ) + print(f" undeclared matl/station keys {extras or 'none'}") + + +async def report_thumbnail(client: FlashForgeClient, file_name: str, out: str | None) -> None: + """Check whether /gcodeThumb serves a thumbnail for an arbitrary stored file. + + The integration only ever asks for the *currently printing* file. The + Creator 5 lists /gcodeThumb among its endpoints, so a per-file preview may + be possible even on printers whose /gcodeList carries no metadata. + """ + print(f"\nRequesting {Endpoints.GCODE_THUMB} for '{file_name}'") + data = await client.files.get_gcode_thumbnail(file_name) + if not data: + print(" no thumbnail returned (endpoint unsupported, or none stored).") + return + + kind = "PNG" if data[:8] == b"\x89PNG\r\n\x1a\n" else f"unknown ({data[:8]!r})" + print(f" received {len(data)} bytes, format: {kind}") + if out: + Path(out).write_bytes(data) + print(f" written to {out}") + + async def report_raw(client: FlashForgeClient) -> None: """Dump the untouched /gcodeList payload and how the library models parse it. @@ -202,8 +299,14 @@ async def main() -> None: parser.add_argument( "--raw", action="store_true", - help="dump the untouched /gcodeList payload and the model parse result", + help="dump the untouched /detail and /gcodeList payloads and the parse results", + ) + parser.add_argument( + "--thumb", + metavar="FILE", + help="ask /gcodeThumb for this stored file's preview image", ) + parser.add_argument("--thumb-out", metavar="PATH", help="save the --thumb image here") parser.add_argument("--print", dest="print_file", help="START a print of this file") parser.add_argument("--leveling", action="store_true", help="level the bed first") parser.add_argument("--yes", action="store_true", help="confirm the print start") @@ -227,8 +330,12 @@ async def main() -> None: report_files(entries, info) if args.raw: + await report_raw_detail(client) await report_raw(client) + if args.thumb: + await report_thumbnail(client, args.thumb, args.thumb_out) + if not args.print_file: print("\nRead-only run. Pass --print --yes to start a print.") return diff --git a/tests/ha_mocks.py b/tests/ha_mocks.py index 578efb3..ae02633 100644 --- a/tests/ha_mocks.py +++ b/tests/ha_mocks.py @@ -49,6 +49,13 @@ def __class_getitem__(cls, item): """Make class subscriptable for type hints like CoordinatorEntity[Coordinator].""" return cls + async def async_added_to_hass(self) -> None: + """Stub for the registration hook; a no-op outside a running HA.""" + + def _handle_coordinator_update(self) -> None: + """Mirror HA's behavior: a coordinator update writes the state back.""" + self.async_write_ha_state() + class DataUpdateCoordinator: """Stub for homeassistant.helpers.update_coordinator.DataUpdateCoordinator.""" @@ -110,6 +117,16 @@ class SwitchEntity(Entity): pass +class ImageEntity(Entity): + """Stub for homeassistant.components.image.ImageEntity.""" + + _attr_image_last_updated = None + + def __init__(self, hass=None) -> None: + """Initialize the image entity.""" + self.hass = hass + + class Camera(Entity): """Stub for homeassistant.components.camera.Camera.""" @@ -397,6 +414,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_material_station_gating.py b/tests/unit/test_material_station_gating.py new file mode 100644 index 0000000..b2cc7ce --- /dev/null +++ b/tests/unit/test_material_station_gating.py @@ -0,0 +1,241 @@ +"""Unit tests for Material Station capability detection and entity gating. + +Regression cover for the Creator 5 Pro (pid 41): its ``/detail`` response omits +``hasMatlStation`` entirely, so ``FFMachineInfo.has_matl_station`` is None while +``matl_station_info`` is fully populated. Gating on the raw flag hid the four +slot swatches and the active-slot sensor on exactly the models v1.3.0 meant to +enable them for. +""" + +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 import image as image_platform +from custom_components.flashforge import sensor as sensor_platform +from custom_components.flashforge.const import DOMAIN +from custom_components.flashforge.util import has_material_station +from flashforge.api.controls.info import MachineInfoParser +from flashforge.models.responses import FFPrinterDetail + + +def _station_payload() -> dict: + """The Material Station block a real Creator 5 Pro reports.""" + return { + "currentLoadSlot": 0, + "currentSlot": 2, + "slotCnt": 4, + "stateAction": 0, + "stateStep": 0, + "slotInfos": [ + {"hasFilament": True, "materialColor": "#1B1B1B", "materialName": "PLA", "slotId": 1}, + {"hasFilament": True, "materialColor": "#1B1B1B", "materialName": "PETG", "slotId": 2}, + {"hasFilament": True, "materialColor": "#FFFFFF", "materialName": "PLA", "slotId": 3}, + {"hasFilament": True, "materialColor": "#805003", "materialName": "PLA", "slotId": 4}, + ], + } + + +def _detail(**overrides) -> FFPrinterDetail: + """Build a Creator 5 Pro /detail payload; note it carries no hasMatlStation.""" + payload = {"pid": 41, "model": "Creator 5 Pro", "name": "Workshop", "status": "ready"} + payload.update(overrides) + return FFPrinterDetail(**payload) + + +def _machine_info(**detail_overrides): + """Build an FFMachineInfo the way the library does, from a /detail payload.""" + return MachineInfoParser.from_detail(_detail(**detail_overrides)) + + +# --------------------------------------------------------------------------- # +# Capability helper +# --------------------------------------------------------------------------- # + + +def test_creator5_pro_reports_no_flag_but_a_populated_station(): + """The firmware sends no flag; the slot data is the real signal. + + Asserted against the raw ``FFPrinterDetail`` on purpose: whether + ``FFMachineInfo.has_matl_station`` stays None or gets derived depends on the + installed library version (ff-5mp-api-py derives it from >1.3.1 onward), but + the integration must gate correctly either way. + """ + detail = _detail(matlStationInfo=_station_payload()) + + assert detail.has_matl_station is None # the printer sends no hasMatlStation + assert has_material_station(MachineInfoParser.from_detail(detail)) is True + + +def test_ad5x_flag_alone_is_enough(): + """AD5X firmware sets the flag; no station block needed.""" + assert has_material_station(Mock(has_matl_station=True, matl_station_info=None)) is True + + +def test_no_station_at_all(): + info = _machine_info() + + assert info.matl_station_info is None + assert has_material_station(info) is False + + +def test_none_data(): + assert has_material_station(None) is False + + +def test_slot_count_without_slot_infos(): + """A station reporting only its size still counts as present.""" + station = Mock(slot_cnt=4, slot_infos=[]) + assert has_material_station(Mock(has_matl_station=None, matl_station_info=station)) is True + + +def test_empty_station_object(): + station = Mock(slot_cnt=0, slot_infos=[]) + assert has_material_station(Mock(has_matl_station=None, matl_station_info=station)) is False + + +def test_active_slot_sensor_reads_through_on_creator5_pro(): + """The active-slot value_fn no longer returns None because the flag is unset.""" + info = _machine_info(matlStationInfo=_station_payload()) + + assert sensor_platform._active_ifs_slot(info) == 2 + + +# --------------------------------------------------------------------------- # +# Platform setup +# --------------------------------------------------------------------------- # + + +class _Coordinator: + """Minimal coordinator that can replay updates to registered listeners.""" + + def __init__(self, data=None): + self.data = data + self.last_update_success = True + self.device_model = "Creator 5 Pro" + self.listeners = [] + + def async_add_listener(self, update_callback): + self.listeners.append(update_callback) + return lambda: self.listeners.remove(update_callback) + + def push(self, data): + """Deliver a coordinator refresh.""" + self.data = data + for update_callback in list(self.listeners): + update_callback() + + +def _setup_args(coordinator): + hass = Mock() + hass.data = {DOMAIN: {"entry-1": {"coordinator": coordinator, "name": "Workshop"}}} + entry = Mock(entry_id="entry-1") + + added: list = [] + + def async_add_entities(new_entities): + added.extend(new_entities) + + return hass, entry, async_add_entities, added + + +@pytest.mark.asyncio +async def test_image_setup_adds_slot_swatches_for_creator5_pro(): + coordinator = _Coordinator(_machine_info(matlStationInfo=_station_payload())) + hass, entry, async_add_entities, added = _setup_args(coordinator) + + await image_platform.async_setup_entry(hass, entry, async_add_entities) + + slots = [e for e in added if isinstance(e, image_platform.FlashForgeMaterialStationSlotImage)] + assert [e._slot_id for e in slots] == [1, 2, 3, 4] + assert [e._swatch_key() for e in slots] == [ + ("PLA", "#1B1B1B"), + ("PETG", "#1B1B1B"), + ("PLA", "#FFFFFF"), + ("PLA", "#805003"), + ] + assert all(e.available for e in slots) + # Nothing left to wait for, so no listener is kept around. + assert coordinator.listeners == [] + + +@pytest.mark.asyncio +async def test_image_setup_skips_slot_swatches_without_a_station(): + coordinator = _Coordinator(_machine_info()) + hass, entry, async_add_entities, added = _setup_args(coordinator) + + await image_platform.async_setup_entry(hass, entry, async_add_entities) + + assert not any( + isinstance(e, image_platform.FlashForgeMaterialStationSlotImage) for e in added + ) + + +@pytest.mark.asyncio +async def test_image_setup_adds_slot_swatches_reported_late(): + """First refresh failed / had no station: the swatches still show up later.""" + coordinator = _Coordinator(None) + hass, entry, async_add_entities, added = _setup_args(coordinator) + + await image_platform.async_setup_entry(hass, entry, async_add_entities) + assert not any( + isinstance(e, image_platform.FlashForgeMaterialStationSlotImage) for e in added + ) + + coordinator.push(_machine_info(matlStationInfo=_station_payload())) + + slots = [e for e in added if isinstance(e, image_platform.FlashForgeMaterialStationSlotImage)] + assert len(slots) == image_platform.IFS_SLOT_COUNT + + # A further refresh must not add them a second time. + coordinator.push(_machine_info(matlStationInfo=_station_payload())) + assert len( + [e for e in added if isinstance(e, image_platform.FlashForgeMaterialStationSlotImage)] + ) == image_platform.IFS_SLOT_COUNT + + +@pytest.mark.asyncio +async def test_sensor_setup_adds_active_slot_sensor_for_creator5_pro(): + coordinator = _Coordinator(_machine_info(matlStationInfo=_station_payload())) + hass, entry, async_add_entities, added = _setup_args(coordinator) + + await sensor_platform.async_setup_entry(hass, entry, async_add_entities) + + keys = [e.entity_description.key for e in added] + assert "active_ifs_slot" in keys + assert keys.count("active_ifs_slot") == 1 + + +@pytest.mark.asyncio +async def test_sensor_setup_adds_active_slot_sensor_reported_late(): + coordinator = _Coordinator(None) + hass, entry, async_add_entities, added = _setup_args(coordinator) + + await sensor_platform.async_setup_entry(hass, entry, async_add_entities) + assert "active_ifs_slot" not in [e.entity_description.key for e in added] + + coordinator.push(_machine_info(matlStationInfo=_station_payload())) + keys = [e.entity_description.key for e in added] + assert keys.count("active_ifs_slot") == 1 + + coordinator.push(_machine_info(matlStationInfo=_station_payload())) + assert [e.entity_description.key for e in added].count("active_ifs_slot") == 1 + + +@pytest.mark.asyncio +async def test_sensor_setup_omits_active_slot_sensor_without_a_station(): + coordinator = _Coordinator(_machine_info()) + hass, entry, async_add_entities, added = _setup_args(coordinator) + + await sensor_platform.async_setup_entry(hass, entry, async_add_entities) + + assert "active_ifs_slot" not in [e.entity_description.key for e in added] diff --git a/tests/unit/test_print_file_entities.py b/tests/unit/test_print_file_entities.py index 245cf37..4d34a3c 100644 --- a/tests/unit/test_print_file_entities.py +++ b/tests/unit/test_print_file_entities.py @@ -85,6 +85,19 @@ def test_file_metadata_is_exposed_as_attributes(): } +@pytest.mark.unit +def test_unreported_metadata_is_omitted_not_faked(): + """The Creator 5 series returns file names only - see scripts/file_print_probe.py. + + Reporting 0 / False there would claim a multi-material file is a + single-material one, so unknown values are left out entirely. + """ + names_only = FFGcodeFileEntry(gcode_file_name="deckel_mit_logo.3mf", printing_time=0) + select, _, _, _, _ = _build(files=[names_only]) + + assert select.extra_state_attributes["files"] == [{"name": "deckel_mit_logo.3mf"}] + + @pytest.mark.unit @pytest.mark.asyncio async def test_selecting_a_file_records_it(): diff --git a/tests/unit/test_print_job.py b/tests/unit/test_print_job.py index 8270e40..ba25f3c 100644 --- a/tests/unit/test_print_job.py +++ b/tests/unit/test_print_job.py @@ -86,6 +86,12 @@ def test_material_station_detection(): ) # A plain multi-tool file that does not use the station prints as-is. assert needs_material_station(_file_entry(tools=[_tool()])) is False + # Creator 5 series: /gcodeList reports names only, so nothing is known about + # the file. It is started as-is and the printer uses the assignment stored + # in the file - we must not invent a mapping here. + assert needs_material_station(FFGcodeFileEntry( + gcode_file_name="deckel_mit_logo.3mf", printing_time=0 + )) is False @pytest.mark.unit From d5d2792e9bc487381d41de129d6ad0d6b6c805db Mon Sep 17 00:00:00 2001 From: RedAces Date: Sat, 25 Jul 2026 21:22:32 +0200 Subject: [PATCH 04/10] Record what the Creator 5 hardware test actually proved A Creator 5 Pro accepts and starts a three-material file sent via start_creator5_job without materialMappings - the firmware falls back to the tool/slot assignment stored in the 3MF. The print file service therefore needs no mapping input on this model. The test job was cancelled seconds after the start, so the resulting color assignment itself is not confirmed; the notes say so explicitly rather than implying an end-to-end verification. --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- README.md | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcc890e..00887f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Notes - The printer's HTTP API only reports its most recent files (10 on current firmware); older files can still be printed by passing `file_name` to `flashforge.print_file`. The TCP full-directory listing is deliberately not used — this integration stays HTTP-only. - Per-file metadata depends on the model: the AD5X returns `gcodeListDetail` with print time, filament weight, and per-tool material data, while the Creator 5 series (verified on a Creator 5 Pro, firmware PID 41) returns plain file names. On those printers multi-material files are therefore sent without mappings and the printer uses the tool/slot assignment stored in the file. `scripts/file_print_probe.py` dumps what a given printer actually reports. +- Hardware-verified on a Creator 5 Pro: starting a single- and a three-material file from Home Assistant works, the printer accepts the job and begins printing. The run was cancelled shortly after the start, so the resulting tool-to-slot **color assignment itself has not been confirmed end to end** — only that the firmware accepts the job without mappings. ## [1.3.1] - 2026-07-23 diff --git a/CLAUDE.md b/CLAUDE.md index fb199c1..3068b5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ Treat this file as the living source of truth for workflows and expectations—u - `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) and the print file select (`FlashForgeFileSelect`, options = the printer's file list, metadata in `extra_state_attributes`). Also registers the `flashforge.print_file` entity service. - `button.py` – Pause / resume / cancel / clear-status commands plus `FlashForgePrintSelectedFileButton`; request a refresh after each action. -- `print_job.py` – Per-model dispatch for starting a file already on the printer (`start_creator5_job` / AD5X single+multi color / `print_local_file`) and `build_material_mappings()`, which derives Material Station mappings from the file's tool data plus the printer's slot colors. Raises `ServiceValidationError` instead of guessing an incomplete mapping. **Per-file metadata is model-dependent**: the AD5X returns `gcodeListDetail` (print time, weight, per-tool material data), a Creator 5 Pro returns plain file names — verified on hardware with `scripts/file_print_probe.py`. Unknown values must stay unknown (`select.file_attributes()` omits them); treating them as `0`/`False` would make a multi-material file look single-material. +- `print_job.py` – Per-model dispatch for starting a file already on the printer (`start_creator5_job` / AD5X single+multi color / `print_local_file`) and `build_material_mappings()`, which derives Material Station mappings from the file's tool data plus the printer's slot colors. Raises `ServiceValidationError` instead of guessing an incomplete mapping. **Per-file metadata is model-dependent**: the AD5X returns `gcodeListDetail` (print time, weight, per-tool material data), a Creator 5 Pro returns plain file names — verified on hardware with `scripts/file_print_probe.py`. Unknown values must stay unknown (`select.file_attributes()` omits them); treating them as `0`/`False` would make a multi-material file look single-material. A Creator 5 Pro was confirmed to accept and start a three-material file sent **without** `materialMappings` — the firmware falls back to the assignment stored in the 3MF, so no mapping input is needed on that model. The resulting color assignment itself is unverified (the test job was cancelled right after the start). - `services.yaml` – Service definition for `flashforge.print_file` (keep in sync with the `services` block in `strings.json`). - `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. diff --git a/README.md b/README.md index 71502ff..15855f9 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,8 @@ file's own tool data combined with the colors the printer reports for the loaded but only on printers that report that tool data (the AD5X). If the data is present but incomplete, the print is refused with an error rather than mapping materials by guesswork. The Creator 5 series reports no per-file tool data at all, so multi-material files are sent -without mappings and the printer uses the tool/slot assignment stored in the file itself. +without mappings and the printer uses the tool/slot assignment stored in the file itself — +verified on a Creator 5 Pro, which accepted and started a three-material file this way. The file list is refreshed every 60 seconds. To refresh it immediately (e.g. right after an upload), call `homeassistant.update_entity` on `select.flashforge_print_file`. From 2a46463d9130a09fe465aa9b012202129651810a Mon Sep 17 00:00:00 2001 From: RedAces Date: Sat, 25 Jul 2026 21:30:56 +0200 Subject: [PATCH 05/10] remove auto-generated pyproject.toml --- pyproject.toml | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index ec2086f..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[project] -name = "flashforge-integration" -version = "0.1.0" -requires-python = ">=3.14" -dependencies = [] From 1131e447713ca0c04275077badab72f917cea31e Mon Sep 17 00:00:00 2001 From: RedAces Date: Sat, 25 Jul 2026 21:44:59 +0200 Subject: [PATCH 06/10] Do not tie the print button's availability to the file selection A button entity is stateless - its state is only the last-press timestamp - so every write of that state is reported as a press. Gating availability on the selection meant picking a file flipped the button from unavailable to available, which the logbook showed as "Print Selected File pressed" although nothing was pressed and no print started. Availability now reflects the printer's reachability only, per the entity-unavailable rule, and selecting a file no longer notifies the coordinator's listeners. Pressing with nothing selected - or with a file that has since disappeared from the printer - raises a ServiceValidationError. --- AGENTS.md | 1 + CHANGELOG.md | 2 +- CLAUDE.md | 2 +- README.md | 4 ++-- custom_components/flashforge/button.py | 17 +++++++++----- custom_components/flashforge/select.py | 6 +++-- tests/unit/test_print_file_entities.py | 32 ++++++++++++++++++-------- 7 files changed, 43 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0785607..9eb6fd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ 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 gate a capability on a single raw `/detail` field.** Some `FFMachineInfo` fields are verbatim copies of the printer's JSON and are absent on models that clearly have the feature: a Creator 5 Pro omits `hasMatlStation` while reporting four loaded slots in `matlStationInfo`, so `has_matl_station` parses as `None` and the Material Station entities never appeared. Use `util.has_material_station()` (flag OR populated slot data), and confirm against `scripts/file_print_probe.py --raw`, which dumps the untouched `/detail` and `/gcodeList` payloads. +- **Never make a `button` entity's availability depend on user input.** Buttons are stateless — their state is only the last-press timestamp, so any state write (including an `unavailable` → available transition triggered by `coordinator.async_update_listeners()`) is rendered as "pressed" in the logbook. Gating "Print Selected File" on the file selection made picking a file emit a phantom press. Availability means "the device is reachable"; validate input in `async_press` and raise `ServiceValidationError`. - **Conditional entities must tolerate a late capability.** Platform setup runs once; filtering on `coordinator.data` there drops entities forever if the first refresh failed or the capability reported in late. `sensor.py` / `image.py` re-check on `coordinator.async_add_listener` and add the entities when the gate first passes — follow that pattern. - **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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00887f8..a7b64ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Local file list and print start.** The files stored on the printer are now visible in Home Assistant and a print can be started from them: - `select._print_file` lists the printer's files (from the HTTP `/gcodeList` endpoint) and records which one to print. Whatever per-file metadata the printer reports — print time, filament weight, tool count, Material Station flag — is exposed via `extra_state_attributes["files"]` for cards and templates. Values the printer does not report are omitted rather than reported as `0`/`false`. - - `button._print_selected_file` starts the selected file. It stays unavailable while no file is selected. + - `button._print_selected_file` starts the selected file. Pressing it without a selection raises an error rather than starting anything; its availability follows the printer's reachability only, because a button is stateless and every write of its state is reported as a press. - `flashforge.print_file` service (targets the print file select entity) with optional `file_name` and `leveling_before_print` fields, so automations can start any file on the printer — including ones outside the reported list. - New option **"Level the bed before starting a print"** (default off) supplies the default for the button and the service. - Material Station files are started with the per-tool mappings derived from the file's own tool data plus the colors the printer reports for the loaded slots. When that data is present but incomplete the print is refused with an error telling the user to start it from the slicer instead of guessing a mapping. diff --git a/CLAUDE.md b/CLAUDE.md index 3068b5e..4bb0777 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -350,7 +350,7 @@ pytest tests/unit/test_sensor_value_functions.py -v - Binary sensors: printing, online, error, paused. - Switch: LED (may show unavailable on unsupported models unless override is enabled). - Select: filtration mode — Off / Internal / External (AD5X only); print file — lists the printer's files. - - Buttons: pause, resume, cancel, clear status, print selected file (unavailable until a file is selected). + - Buttons: pause, resume, cancel, clear status, print selected file (errors when pressed with nothing selected). - Service: `flashforge.print_file` on the print file select, with and without `file_name` / `leveling_before_print`. - Camera: MJPEG feed reachable. - Image: g-code thumbnail of the active print. diff --git a/README.md b/README.md index 15855f9..e460df4 100644 --- a/README.md +++ b/README.md @@ -305,8 +305,8 @@ `select.flashforge_print_file` lists the files on the printer and records which one you want -to print; `button.flashforge_print_selected_file` starts it. The button stays unavailable -until a file is selected. +to print; `button.flashforge_print_selected_file` starts it. Pressing it without a selection +reports an error instead of starting anything. The printer's HTTP API reports its **most recent files** (10 on current firmware), so the dropdown is not a full directory listing. Files outside that list can still be printed by diff --git a/custom_components/flashforge/button.py b/custom_components/flashforge/button.py index 47f450b..50ace2f 100644 --- a/custom_components/flashforge/button.py +++ b/custom_components/flashforge/button.py @@ -158,20 +158,25 @@ def __init__( @property def available(self) -> bool: - """Return if a file is selected and the printer is reachable.""" - selected = self.coordinator.selected_file + """Return if the printer is reachable. + + Deliberately not tied to the file selection: availability means "we can + reach the device", and a button is stateless, so every write of its state + reads as a press in the logbook. Pressing without a selection raises a + ServiceValidationError instead. + """ return ( self.coordinator.last_update_success and self._machine_coordinator.last_update_success - and selected is not None - and selected in self.coordinator.file_names ) async def async_press(self) -> None: """Start printing the selected file.""" selected = self.coordinator.selected_file - if selected is None: - raise ServiceValidationError("No file selected to print") + if selected is None or selected not in self.coordinator.file_names: + raise ServiceValidationError( + "No file selected to print - pick one on the print file entity first" + ) leveling_before_print = self._entry.options.get( CONF_LEVELING_BEFORE_PRINT, DEFAULT_LEVELING_BEFORE_PRINT diff --git a/custom_components/flashforge/select.py b/custom_components/flashforge/select.py index f13d2d9..1df132c 100644 --- a/custom_components/flashforge/select.py +++ b/custom_components/flashforge/select.py @@ -253,8 +253,10 @@ async def async_select_option(self, option: str) -> None: raise ServiceValidationError(f"'{option}' is not a file on the printer") self.coordinator.selected_file = option - # Also refreshes the print button, whose availability follows the selection. - self.coordinator.async_update_listeners() + # Only this entity's state changed. Notifying the coordinator's listeners + # would also rewrite the stateless print button's state, which the + # logbook reports as a button press that never happened. + self.async_write_ha_state() async def async_print_file( self, diff --git a/tests/unit/test_print_file_entities.py b/tests/unit/test_print_file_entities.py index 4d34a3c..a6e9790 100644 --- a/tests/unit/test_print_file_entities.py +++ b/tests/unit/test_print_file_entities.py @@ -18,7 +18,7 @@ from custom_components.flashforge.coordinator import FlashForgeFileListCoordinator from custom_components.flashforge.select import FlashForgeFileSelect from flashforge.models import FFGcodeFileEntry -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError def _entry(name: str, printing_time: int = 3600) -> FFGcodeFileEntry: @@ -109,9 +109,9 @@ async def test_selecting_a_file_records_it(): assert file_coordinator.selected_file == "bracket.gcode" assert select.current_option == "bracket.gcode" client.job_control.assert_not_called() - # The print button's availability follows the selection, so it has to be - # told about it right away instead of waiting for the next poll. - file_coordinator.async_update_listeners.assert_called_once() + # Selecting must not touch the button. It is stateless, so rewriting its + # state shows up in the logbook as a press that never happened. + file_coordinator.async_update_listeners.assert_not_called() assert button.available is True @@ -191,18 +191,21 @@ async def test_print_file_service_without_a_file_errors(): @pytest.mark.unit -def test_button_needs_a_selected_file(): - """Without a selection there is nothing to print.""" +def test_button_availability_does_not_follow_the_selection(): + """A button is stateless: every state write reads as a press in the logbook. + + Availability therefore means "the printer is reachable" only - gating it on + the selection made selecting a file emit a phantom "pressed" entry. + """ _, button, file_coordinator, _, _ = _build() - assert button.available is False + assert button.available is True file_coordinator.selected_file = "benchy.3mf" assert button.available is True - # A selection that is no longer on the printer keeps the button disabled. file_coordinator.selected_file = "gone.gcode" - assert button.available is False + assert button.available is True @pytest.mark.unit @@ -219,6 +222,17 @@ def test_button_unavailable_when_the_printer_is_unreachable(): assert button.available is False +@pytest.mark.unit +@pytest.mark.asyncio +async def test_button_with_a_stale_selection_errors(): + """A file deleted on the printer must not be sent as a print job.""" + _, button, file_coordinator, _, _ = _build() + file_coordinator.selected_file = "gone.gcode" + + with pytest.raises(ServiceValidationError): + await button.async_press() + + @pytest.mark.unit @pytest.mark.asyncio async def test_button_starts_the_selected_file(): From fcbb6715d63e409cba576a63cc3ee79d84e8dc3b Mon Sep 17 00:00:00 2001 From: RedAces Date: Sat, 25 Jul 2026 21:51:17 +0200 Subject: [PATCH 07/10] Rename the file select to "Local File Selection" "Print File" reads like an action, so the logbook entry for picking a file looked like a print had been started. The entity only records a choice; the button and the service are what start a print. --- AGENTS.md | 4 ++-- CHANGELOG.md | 4 ++-- CLAUDE.md | 8 ++++---- README.md | 16 ++++++++-------- custom_components/flashforge/button.py | 5 +++-- custom_components/flashforge/select.py | 10 +++++----- custom_components/flashforge/strings.json | 4 ++-- .../flashforge/translations/en.json | 4 ++-- 8 files changed, 28 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9eb6fd1..ba70933 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Working agreement for AI assistants contributing to `ff-5mp-hass`. - **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:** 58 total (38 sensors, 5 binary sensors, 2 switches, 2 selects, 5 buttons, 1 camera, 5 images — the g-code thumbnail plus 4 Material Station slot color swatches). -- **Services:** `flashforge.print_file` — starts a file already stored on the printer (entity service on the print file select). +- **Services:** `flashforge.print_file` — starts a file already stored on the printer (entity service on the Local File Selection entity). - **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`. @@ -68,7 +68,7 @@ Working agreement for AI assistants contributing to `ff-5mp-hass`. - Sensors: machine status, nozzle temps/targets, bed temps/targets, progress, file, current/total layers, elapsed/remaining time, filament length/weight, print speed, z offset, move mode, nozzle size, filament type. - Binary sensors: printing, online, error, paused. - Switches: LED and camera power (may show unavailable if unsupported). - - Select: filtration mode (may show unavailable if unsupported); print file (lists the printer's files). + - Select: filtration mode (may show unavailable if unsupported); Local File Selection (lists the printer's files, selecting starts nothing). - Buttons: pause, resume, cancel, clear status, print selected file. - Service: `flashforge.print_file` (defaults to the selected file, accepts any file name on the printer). - Camera: entity exists and becomes available when the printer reports an OEM stream URL. diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b64ef..abc1b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Local file list and print start.** The files stored on the printer are now visible in Home Assistant and a print can be started from them: - - `select._print_file` lists the printer's files (from the HTTP `/gcodeList` endpoint) and records which one to print. Whatever per-file metadata the printer reports — print time, filament weight, tool count, Material Station flag — is exposed via `extra_state_attributes["files"]` for cards and templates. Values the printer does not report are omitted rather than reported as `0`/`false`. + - `select._local_file_selection` ("Local File Selection") lists the printer's files (from the HTTP `/gcodeList` endpoint) and records which one to print. Whatever per-file metadata the printer reports — print time, filament weight, tool count, Material Station flag — is exposed via `extra_state_attributes["files"]` for cards and templates. Values the printer does not report are omitted rather than reported as `0`/`false`. - `button._print_selected_file` starts the selected file. Pressing it without a selection raises an error rather than starting anything; its availability follows the printer's reachability only, because a button is stateless and every write of its state is reported as a press. - - `flashforge.print_file` service (targets the print file select entity) with optional `file_name` and `leveling_before_print` fields, so automations can start any file on the printer — including ones outside the reported list. + - `flashforge.print_file` service (targets the Local File Selection entity) with optional `file_name` and `leveling_before_print` fields, so automations can start any file on the printer — including ones outside the reported list. - New option **"Level the bed before starting a print"** (default off) supplies the default for the button and the service. - Material Station files are started with the per-tool mappings derived from the file's own tool data plus the colors the printer reports for the loaded slots. When that data is present but incomplete the print is refused with an error telling the user to start it from the slicer instead of guessing a mapping. - The file list is polled by its own coordinator every 60 s (independent of the machine-state interval) and is included in diagnostics. diff --git a/CLAUDE.md b/CLAUDE.md index 4bb0777..fe9a883 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for AI coding assistants working in this repository. - Integration **version 1.3.0** (in-flight; not yet tagged). - Provides a complete Home Assistant experience for FlashForge printers using the **HTTP API only**. - Entities shipped: **58 total** (38 sensors, 5 binary sensors, 2 switches, 5 buttons, 2 selects, 1 MJPEG camera, 5 images — the g-code thumbnail plus 4 Material Station slot color swatches). -- One service: `flashforge.print_file` (entity service on the print file select). +- One service: `flashforge.print_file` (entity service on the Local File Selection entity). - Diagnostics download supported (`diagnostics.py`), with credentials and identifiers redacted. - Reauthentication and reconfigure flows supported in addition to the original setup paths. - UI config flow supports automatic discovery, manual entry, credential validation, and an adjustable polling interval (5–300 s, default 10 s). @@ -82,7 +82,7 @@ Treat this file as the living source of truth for workflows and expectations—u - `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) and the print file select (`FlashForgeFileSelect`, options = the printer's file list, metadata in `extra_state_attributes`). Also registers the `flashforge.print_file` entity service. +- `select.py` – Filtration mode select (Off / Internal / External, AD5X only) and the Local File Selection entity (`FlashForgeFileSelect`, options = the printer's file list, metadata in `extra_state_attributes`). Also registers the `flashforge.print_file` entity service. - `button.py` – Pause / resume / cancel / clear-status commands plus `FlashForgePrintSelectedFileButton`; request a refresh after each action. - `print_job.py` – Per-model dispatch for starting a file already on the printer (`start_creator5_job` / AD5X single+multi color / `print_local_file`) and `build_material_mappings()`, which derives Material Station mappings from the file's tool data plus the printer's slot colors. Raises `ServiceValidationError` instead of guessing an incomplete mapping. **Per-file metadata is model-dependent**: the AD5X returns `gcodeListDetail` (print time, weight, per-tool material data), a Creator 5 Pro returns plain file names — verified on hardware with `scripts/file_print_probe.py`. Unknown values must stay unknown (`select.file_attributes()` omits them); treating them as `0`/`False` would make a multi-material file look single-material. A Creator 5 Pro was confirmed to accept and start a three-material file sent **without** `materialMappings` — the firmware falls back to the assignment stored in the 3MF, so no mapping input is needed on that model. The resulting color assignment itself is unverified (the test job was cancelled right after the start). - `services.yaml` – Service definition for `flashforge.print_file` (keep in sync with the `services` block in `strings.json`). @@ -349,9 +349,9 @@ pytest tests/unit/test_sensor_value_functions.py -v - 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. - Switch: LED (may show unavailable on unsupported models unless override is enabled). - - Select: filtration mode — Off / Internal / External (AD5X only); print file — lists the printer's files. + - Select: filtration mode — Off / Internal / External (AD5X only); Local File Selection — lists the printer's files. - Buttons: pause, resume, cancel, clear status, print selected file (errors when pressed with nothing selected). - - Service: `flashforge.print_file` on the print file select, with and without `file_name` / `leveling_before_print`. + - Service: `flashforge.print_file` on the Local File Selection entity, with and without `file_name` / `leveling_before_print`. - 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. diff --git a/README.md b/README.md index e460df4..82ebe07 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ | Entity | Description | Options | Availability | |--------|-------------|---------|--------------| | `select.flashforge_filtration_mode` | Control filtration system | Off, Internal, External | 5M Pro / Creator 5 Pro | -| `select.flashforge_print_file` | Files stored on the printer; picks the one to print | Printer's file list | All Models | +| `select.flashforge_local_file_selection` | Picks which of the files stored on the printer to print — selecting does not start anything | Printer's file list | All Models | @@ -280,7 +280,7 @@ | `button.flashforge_resume_print` | Resume paused print job | | `button.flashforge_cancel_print` | Cancel and abort print job | | `button.flashforge_clear_status` | Clear printer status/errors | -| `button.flashforge_print_selected_file` | Start printing the file picked on `select.flashforge_print_file` | +| `button.flashforge_print_selected_file` | Start printing the file picked on `select.flashforge_local_file_selection` | @@ -304,9 +304,9 @@

Printing Files Stored on the Printer

-`select.flashforge_print_file` lists the files on the printer and records which one you want -to print; `button.flashforge_print_selected_file` starts it. Pressing it without a selection -reports an error instead of starting anything. +`select.flashforge_local_file_selection` lists the files on the printer and records which one you want +to print — picking one starts nothing. `button.flashforge_print_selected_file` is what starts the +print; pressing it without a selection reports an error instead of starting anything. The printer's HTTP API reports its **most recent files** (10 on current firmware), so the dropdown is not a full directory listing. Files outside that list can still be printed by @@ -315,7 +315,7 @@ passing their name to the service: ```yaml action: flashforge.print_file target: - entity_id: select.flashforge_print_file + entity_id: select.flashforge_local_file_selection data: file_name: benchy.3mf # optional, defaults to the selected file leveling_before_print: true # optional, defaults to the integration option @@ -324,7 +324,7 @@ data: Whatever metadata the printer reports per file is available for cards and templates: ```yaml -{{ state_attr('select.flashforge_print_file', 'files') }} +{{ state_attr('select.flashforge_local_file_selection', 'files') }} # AD5X (reports gcodeListDetail): # [{'name': 'benchy.3mf', 'printing_time': 3600, 'filament_weight': 25.5, # 'tool_count': 1, 'uses_material_station': False}, ...] @@ -348,7 +348,7 @@ without mappings and the printer uses the tool/slot assignment stored in the fil verified on a Creator 5 Pro, which accepted and started a three-material file this way. The file list is refreshed every 60 seconds. To refresh it immediately (e.g. right after an -upload), call `homeassistant.update_entity` on `select.flashforge_print_file`. +upload), call `homeassistant.update_entity` on `select.flashforge_local_file_selection`.

Usage Examples

diff --git a/custom_components/flashforge/button.py b/custom_components/flashforge/button.py index 50ace2f..6f8c8da 100644 --- a/custom_components/flashforge/button.py +++ b/custom_components/flashforge/button.py @@ -132,7 +132,7 @@ async def async_press(self) -> None: class FlashForgePrintSelectedFileButton( CoordinatorEntity[FlashForgeFileListCoordinator], ButtonEntity ): - """Starts the print of the file picked on the print file select entity.""" + """Starts the print of the file picked on the Local File Selection entity.""" _attr_has_entity_name = True _attr_translation_key = "print_selected_file" @@ -175,7 +175,8 @@ async def async_press(self) -> None: selected = self.coordinator.selected_file if selected is None or selected not in self.coordinator.file_names: raise ServiceValidationError( - "No file selected to print - pick one on the print file entity first" + "No file selected to print - pick one on the Local File Selection " + "entity first" ) leveling_before_print = self._entry.options.get( diff --git a/custom_components/flashforge/select.py b/custom_components/flashforge/select.py index 1df132c..24bfcc8 100644 --- a/custom_components/flashforge/select.py +++ b/custom_components/flashforge/select.py @@ -95,8 +95,8 @@ async def _async_print_file_service(entity: Entity, call: ServiceCall) -> None: """Handle the ``flashforge.print_file`` entity service.""" if not isinstance(entity, FlashForgeFileSelect): raise ServiceValidationError( - f"{SERVICE_PRINT_FILE} must target a FlashForge print file entity, " - f"got {entity.entity_id}" + f"{SERVICE_PRINT_FILE} must target a FlashForge Local File Selection " + f"entity, got {entity.entity_id}" ) await entity.async_print_file( @@ -206,7 +206,7 @@ class FlashForgeFileSelect( """ _attr_has_entity_name = True - _attr_translation_key = "print_file" + _attr_translation_key = "local_file_selection" _attr_icon = "mdi:file-document-multiple-outline" def __init__( @@ -217,12 +217,12 @@ def __init__( entry: ConfigEntry, printer_name: str, ) -> None: - """Initialize the print file select entity.""" + """Initialize the local file selection entity.""" super().__init__(coordinator) self._machine_coordinator = machine_coordinator self._client = client self._entry = entry - self._attr_unique_id = f"{entry.entry_id}_print_file" + self._attr_unique_id = f"{entry.entry_id}_local_file_selection" self._attr_device_info = build_device_info( machine_coordinator, printer_name, entry.entry_id ) diff --git a/custom_components/flashforge/strings.json b/custom_components/flashforge/strings.json index 68b6ca9..c232011 100644 --- a/custom_components/flashforge/strings.json +++ b/custom_components/flashforge/strings.json @@ -155,7 +155,7 @@ "print_selected_file": { "name": "Print Selected File" } }, "select": { - "print_file": { "name": "Print File" }, + "local_file_selection": { "name": "Local File Selection" }, "filtration_mode": { "name": "Filtration Mode", "state": { @@ -183,7 +183,7 @@ "fields": { "file_name": { "name": "File name", - "description": "Name of the file on the printer. Defaults to the file selected on the Print File entity." + "description": "Name of the file on the printer. Defaults to the file picked on the Local File Selection entity." }, "leveling_before_print": { "name": "Level bed before print", diff --git a/custom_components/flashforge/translations/en.json b/custom_components/flashforge/translations/en.json index 68b6ca9..c232011 100644 --- a/custom_components/flashforge/translations/en.json +++ b/custom_components/flashforge/translations/en.json @@ -155,7 +155,7 @@ "print_selected_file": { "name": "Print Selected File" } }, "select": { - "print_file": { "name": "Print File" }, + "local_file_selection": { "name": "Local File Selection" }, "filtration_mode": { "name": "Filtration Mode", "state": { @@ -183,7 +183,7 @@ "fields": { "file_name": { "name": "File name", - "description": "Name of the file on the printer. Defaults to the file selected on the Print File entity." + "description": "Name of the file on the printer. Defaults to the file picked on the Local File Selection entity." }, "leveling_before_print": { "name": "Level bed before print", From 2bd6fc7f4bdb7ce3f6df353700e94d05eea0ebe7 Mon Sep 17 00:00:00 2001 From: RedAces Date: Sat, 25 Jul 2026 22:02:49 +0200 Subject: [PATCH 08/10] Stop overriding the printer's LED capability report The "Always show LED switch" option was handed to the library as led_control_override using its unset value False. The library reads False as "force LED control off" and only None as "no override", so with the option switched off - the default - the integration overrode the printer's own capability report and pinned led_control to False on every model. The switch stayed greyed out, and the library additionally refused set_led_on/off internally, which made enabling the override look like the only way to get a working switch. The option now passes None when off and True only when explicitly enabled. Verified on a Creator 5 Pro: /product reports lightCtrlState 1 and the lamp switches, so the endpoint was never at fault here. Capability diagnostics move out of file_print_probe.py into a new capability_probe.py: raw /detail and /product dumps, the flags entities are gated on, and a --led command that bypasses the guard. The file probe is back to file listing and print start only. --- CHANGELOG.md | 1 + CLAUDE.md | 3 +- custom_components/flashforge/__init__.py | 7 +- scripts/capability_probe.py | 217 +++++++++++++++++++++++ scripts/file_print_probe.py | 62 +------ tests/unit/test_setup_entry.py | 49 +++++ 6 files changed, 279 insertions(+), 60 deletions(-) create mode 100644 scripts/capability_probe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index abc1b77..d7e5885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The file list is polled by its own coordinator every 60 s (independent of the machine-state interval) and is included in diagnostics. ### Fixed +- **The LED switch is no longer permanently unavailable.** The "Always show LED switch" option was passed to the library as `led_control_override` using its unset value `False`. The library treats `False` as "force LED control off" and only `None` as "no override", so with the option switched off — the default — the integration overrode the printer's own capability report and pinned `client.led_control` to `False` on **every** model. The switch stayed greyed out, 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. The option now sends `None` when it is off and `True` only when the user asks for it. Verified on a Creator 5 Pro: `/product` correctly reports `lightCtrlState: 1`, and the lamp switches on and off — so `/product` was never the problem here. - **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 is a straight copy of the raw `hasMatlStation` field from `/detail`. A Creator 5 Pro does not report that field at all — verified against real hardware (pid 41, firmware 1.9.4): the `hasMatlStation` key is absent from `/detail` entirely, under any name, while `matlStationInfo` reports `slotCnt: 4` and four loaded slots. The flag therefore parsed as `None`, the entities were never created, and the v1.3.0 change that moved the gate off `is_ad5x` had no effect. Capability detection now lives in `util.has_material_station()`, which accepts populated slot data (`slotCnt` / `slotInfos`) as proof of the station, the same way the library's own AD5X heuristic does. - **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 the capability first shows up on a later refresh, so a station that reports in after the first poll — or a first refresh that failed outright — no longer leaves the printer permanently without those entities. diff --git a/CLAUDE.md b/CLAUDE.md index fe9a883..5b5c5a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -361,7 +361,8 @@ pytest tests/unit/test_sensor_value_functions.py -v ### Testing Utilities - **Discovery diagnostics** – `scripts/test_discovery.py` and `scripts/discovery_probe.py` help debug LAN communication without HA. -- **File list / print start** – `scripts/file_print_probe.py` runs the integration's own `print_job` code against a real printer without a HA runtime (it borrows `tests/ha_mocks.py`). Read-only by default; `--raw` dumps the untouched `/gcodeList` payload and how the library's pydantic models parse it; `--print --yes` actually starts a print. +- **File list / print start** – `scripts/file_print_probe.py` runs the integration's own `print_job` code against a real printer without a HA runtime (it borrows `tests/ha_mocks.py`). Read-only by default; `--raw` dumps the untouched `/gcodeList` payload and how the library's pydantic models parse it; `--thumb ` checks the per-file preview; `--print --yes` actually starts a print. +- **Capability diagnosis** – `scripts/capability_probe.py` dumps the raw `/detail` and `/product` payloads next to the flags entities are gated on (`led_control`, `has_material_station()`, door/camera). Use it whenever an entity is unexpectedly greyed out, before assuming the printer lacks the feature. `--led on|off` sends `lightControl_cmd` with the capability guard forced open. - **Hardware caveat** – Full verification requires a FlashForge printer with LAN mode enabled; simulated runs only confirm flow logic. ## Implementation Guard Rails diff --git a/custom_components/flashforge/__init__.py b/custom_components/flashforge/__init__.py index be39cc0..0e8c5d5 100644 --- a/custom_components/flashforge/__init__.py +++ b/custom_components/flashforge/__init__.py @@ -46,13 +46,16 @@ 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. The library distinguishes "no override" (None) + # from "override to off" (False): passing the unset option through as False + # would pin led_control to False and hide the LED switch on every printer, + # whatever /product reported. Only force the capability when asked to. 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/scripts/capability_probe.py b/scripts/capability_probe.py new file mode 100644 index 0000000..076d20e --- /dev/null +++ b/scripts/capability_probe.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Probe how a real printer reports its capabilities, and what the integration makes of them. + +Several capability flags are verbatim copies of the printer's JSON and are wrong +or absent on models that clearly have the feature (see the "never trust /product" +and "never gate on a single raw /detail field" rules in AGENTS.md). This dumps the +untouched ``/detail`` and ``/product`` payloads next to the flags the integration +gates its entities on, so a greyed-out entity can be traced to its source. + +Read-only unless ``--led`` is passed. + +Usage (from the repository root): + + python scripts/capability_probe.py --ip 192.168.1.50 --serial SN123 --check-code ABCD + python scripts/capability_probe.py --ip ... --led on + +Credentials may also come from the environment: FF_IP, FF_SERIAL, FF_CHECK_CODE. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +# The integration modules import Home Assistant; borrow the test mocks so they +# can be imported outside a HA runtime. +from tests.ha_mocks import mock_homeassistant # noqa: E402 + +mock_homeassistant() + +from flashforge import FiveMClientConnectionOptions, FlashForgeClient # noqa: E402 +from flashforge.api.constants.endpoints import Endpoints # noqa: E402 +from flashforge.models.responses import FFPrinterDetail # noqa: E402 + +from custom_components.flashforge.util import has_material_station # noqa: E402 + + +async def connect(ip: str, serial: str, check_code: str) -> FlashForgeClient: + """Set the client up the same way the integration's async_setup_entry does. + + Note the options are left empty: passing ``led_control_override=False`` here + would pin ``led_control`` to False, which is exactly the bug this script is + meant to expose. + """ + client = FlashForgeClient( + ip_address=ip, + serial_number=serial, + check_code=check_code, + options=FiveMClientConnectionOptions(), + ) + info = await client.info.get() + if info is None: + raise SystemExit(f"No answer from {ip} - check IP, serial, and check code.") + client.cache_details(info) + if not await client.send_product_command(): + raise SystemExit("Printer rejected the credentials (check code).") + return client + + +def report_flags(client: FlashForgeClient, info) -> None: + """Print the model identity and the flags entities are gated on.""" + print("Printer") + print(f" model {getattr(info, 'model', None)} (pid={getattr(info, 'pid', None)})") + print(f" is_pro {client.is_pro}") + print(f" is_ad5x {client.is_ad5x}") + print(f" is_creator5 {client.is_creator5}") + print(f" is_creator5_pro {client.is_creator5_pro}") + print(f" http_only {client.http_only}") + + print("\nCapability flags") + print(f" client.led_control {client.led_control} (LED switch)") + print(f" client.filtration_control {client.filtration_control} (unused: the select gates on model identity)") + print(f" has_matl_station (raw) {getattr(info, 'has_matl_station', None)}") + print(f" has_material_station() {has_material_station(info)} (Material Station entities)") + print(f" has_door_sensor {getattr(info, 'has_door_sensor', None)}") + print(f" has_camera {getattr(info, 'has_camera', None)}") + + station = getattr(info, "matl_station_info", None) + for slot in getattr(station, "slot_infos", None) or []: + print( + f" slot {getattr(slot, 'slot_id', '?')}: " + f"material={getattr(slot, 'material_name', '') or '-'} " + f"color={getattr(slot, 'material_color', '') or '-'} " + f"loaded={getattr(slot, 'has_filament', None)}" + ) + + +async def _post(client: FlashForgeClient, endpoint: str) -> dict | None: + payload = { + "serialNumber": client.serial_number, + "checkCode": client.check_code, + } + session = await client.get_http_session() + async with session.post( + client.get_endpoint(endpoint), + json=payload, + headers={"Content-Type": "application/json"}, + ) as response: + print(f"\nRaw POST {endpoint} -> HTTP {response.status}") + return await response.json(content_type=None) + + +async def report_raw_detail(client: FlashForgeClient) -> None: + """Dump the untouched /detail payload and inspect the Material Station keys. + + ``FFMachineInfo.has_matl_station`` is a straight copy of the raw + ``hasMatlStation`` field. The Creator 5 series leaves it None while + ``matlStationInfo`` is fully populated, so this shows whether the printer + omits the flag entirely or reports it under a different name. + """ + data = await _post(client, Endpoints.DETAIL) + print(json.dumps(data, indent=2, ensure_ascii=False)[:6000]) + + detail = data.get("detail") if isinstance(data, dict) else None + if not isinstance(detail, dict): + print("\n No 'detail' object in the response.") + return + + print("\nMaterial Station keys in the raw /detail payload:") + print(f" 'hasMatlStation' present {'hasMatlStation' in detail}") + if "hasMatlStation" in detail: + print(f" hasMatlStation {detail['hasMatlStation']!r}") + print(f" 'matlStationInfo' present {'matlStationInfo' in detail}") + station = detail.get("matlStationInfo") + if isinstance(station, dict): + print(f" slotCnt {station.get('slotCnt')!r}") + print(f" slotInfos entries {len(station.get('slotInfos') or [])}") + + # Any key containing "matl"/"station" the model doesn't declare - would + # reveal the flag hiding behind a different name on this firmware. + known = set(FFPrinterDetail.model_fields) | { + field.alias for field in FFPrinterDetail.model_fields.values() if field.alias + } + extras = sorted( + key + for key in detail + if key not in known and ("matl" in key.lower() or "station" in key.lower()) + ) + print(f" undeclared matl/station keys {extras or 'none'}") + + +async def report_product(client: FlashForgeClient) -> None: + """Dump /product, the source of the LED and filtration capability flags.""" + data = await _post(client, Endpoints.PRODUCT) + print(json.dumps(data, indent=2, ensure_ascii=False)[:4000]) + + product = (data or {}).get("product") if isinstance(data, dict) else None + if isinstance(product, dict): + print("\nCapability-relevant fields:") + for key in ( + "lightCtrlState", + "internalFanCtrlState", + "externalFanCtrlState", + "chamberTempCtrlState", + "cameraCtrlState", + ): + print(f" {key:24} {product.get(key, '')!r}") + + +async def report_led(client: FlashForgeClient, state: str) -> None: + """Send the LED command, bypassing the client's own capability guard. + + ``control.set_led_on/off`` refuse when ``client.led_control`` is False, so + this forces the override first: it answers whether the printer accepts + lightControl_cmd at all, independently of what /product claims. + """ + print(f"\nForcing led_control on and sending lightControl_cmd '{state}'") + client.set_feature_overrides(led_control=True) + + if state == "on": + ok = await client.control.set_led_on() + else: + ok = await client.control.set_led_off() + print(f" printer accepted the command: {ok}") + + info = await client.info.get() + print(f" /detail now reports lights_on: {getattr(info, 'lights_on', None)}") + + +async def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ip", default=os.environ.get("FF_IP")) + parser.add_argument("--serial", default=os.environ.get("FF_SERIAL")) + parser.add_argument("--check-code", default=os.environ.get("FF_CHECK_CODE")) + parser.add_argument( + "--led", + choices=("on", "off"), + help="switch the printer's LED, bypassing the capability guard", + ) + args = parser.parse_args() + + if not (args.ip and args.serial and args.check_code): + parser.error("--ip, --serial and --check-code are required (or FF_* env vars)") + + client = await connect(args.ip, args.serial, args.check_code) + try: + info = await client.info.get() + client.cache_details(info) + report_flags(client, info) + await report_raw_detail(client) + await report_product(client) + + if args.led: + await report_led(client, args.led) + finally: + await client.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/file_print_probe.py b/scripts/file_print_probe.py index 74393fc..2f122e1 100644 --- a/scripts/file_print_probe.py +++ b/scripts/file_print_probe.py @@ -13,6 +13,7 @@ python scripts/file_print_probe.py --discover python scripts/file_print_probe.py --ip 192.168.1.50 --serial SN123 --check-code ABCD python scripts/file_print_probe.py --ip ... --raw + python scripts/file_print_probe.py --ip ... --thumb benchy.3mf --thumb-out t.png python scripts/file_print_probe.py --ip ... --print benchy.3mf --yes Credentials may also come from the environment: FF_IP, FF_SERIAL, FF_CHECK_CODE. @@ -42,7 +43,7 @@ PrinterDiscovery, ) from flashforge.api.constants.endpoints import Endpoints # noqa: E402 -from flashforge.models.responses import FFPrinterDetail, GCodeListResponse # noqa: E402 +from flashforge.models.responses import GCodeListResponse # noqa: E402 from pydantic import ValidationError # noqa: E402 from custom_components.flashforge.print_job import ( # noqa: E402 @@ -50,7 +51,6 @@ build_material_mappings, needs_material_station, ) -from custom_components.flashforge.util import has_material_station # noqa: E402 from homeassistant.exceptions import HomeAssistantError # noqa: E402 @@ -99,10 +99,10 @@ def report_printer(client: FlashForgeClient, info) -> None: print(f" is_creator5_pro {client.is_creator5_pro}") print(f" is_ad5x {client.is_ad5x}") print(f" http_only {client.http_only}") - print(f" has_matl_station {getattr(info, 'has_matl_station', None)} (raw flag)") - print(f" material station {has_material_station(info)} (what the integration gates on)") print(f" state {getattr(info, 'machine_state', None)}") + # Slot colors feed the material mappings below; capability flags belong in + # scripts/capability_probe.py. station = getattr(info, "matl_station_info", None) for slot in getattr(station, "slot_infos", None) or []: print( @@ -183,57 +183,6 @@ def report_files(entries, info) -> None: ) -async def report_raw_detail(client: FlashForgeClient) -> None: - """Dump the untouched /detail payload and inspect the Material Station keys. - - ``FFMachineInfo.has_matl_station`` is a straight copy of the raw - ``hasMatlStation`` field. The Creator 5 series leaves it None while - ``matlStationInfo`` is fully populated, so this shows whether the printer - omits the flag entirely or reports it under a different name. - """ - payload = { - "serialNumber": client.serial_number, - "checkCode": client.check_code, - } - session = await client.get_http_session() - async with session.post( - client.get_endpoint(Endpoints.DETAIL), - json=payload, - headers={"Content-Type": "application/json"}, - ) as response: - print(f"\nRaw POST {Endpoints.DETAIL} -> HTTP {response.status}") - data = await response.json(content_type=None) - - print(json.dumps(data, indent=2, ensure_ascii=False)[:6000]) - - detail = data.get("detail") if isinstance(data, dict) else None - if not isinstance(detail, dict): - print("\n No 'detail' object in the response.") - return - - print("\nMaterial Station keys in the raw /detail payload:") - print(f" 'hasMatlStation' present {'hasMatlStation' in detail}") - if "hasMatlStation" in detail: - print(f" hasMatlStation {detail['hasMatlStation']!r}") - print(f" 'matlStationInfo' present {'matlStationInfo' in detail}") - station = detail.get("matlStationInfo") - if isinstance(station, dict): - print(f" slotCnt {station.get('slotCnt')!r}") - print(f" slotInfos entries {len(station.get('slotInfos') or [])}") - - # Any key containing "matl"/"station" the model doesn't declare - would - # reveal the flag hiding behind a different name on this firmware. - known = set(FFPrinterDetail.model_fields) | { - field.alias for field in FFPrinterDetail.model_fields.values() if field.alias - } - extras = sorted( - key - for key in detail - if key not in known and ("matl" in key.lower() or "station" in key.lower()) - ) - print(f" undeclared matl/station keys {extras or 'none'}") - - async def report_thumbnail(client: FlashForgeClient, file_name: str, out: str | None) -> None: """Check whether /gcodeThumb serves a thumbnail for an arbitrary stored file. @@ -299,7 +248,7 @@ async def main() -> None: parser.add_argument( "--raw", action="store_true", - help="dump the untouched /detail and /gcodeList payloads and the parse results", + help="dump the untouched /gcodeList payload and the model parse result", ) parser.add_argument( "--thumb", @@ -330,7 +279,6 @@ async def main() -> None: report_files(entries, info) if args.raw: - await report_raw_detail(client) await report_raw(client) if args.thumb: diff --git a/tests/unit/test_setup_entry.py b/tests/unit/test_setup_entry.py index 2a18400..01cf5a7 100644 --- a/tests/unit/test_setup_entry.py +++ b/tests/unit/test_setup_entry.py @@ -94,3 +94,52 @@ async def test_async_setup_entry_uses_library_led_override_option(): assert ( hass.data[DOMAIN][entry.entry_id]["file_coordinator"] is file_coordinator ) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_led_override_off_leaves_detection_to_the_printer(): + """An unset LED option must not pin the capability to False. + + The library reads led_control_override=False as "force the LED off", which + hides the switch on every printer regardless of what /product reported. + """ + hass = Mock() + hass.data = {} + hass.config_entries = Mock() + hass.config_entries.async_forward_entry_setups = AsyncMock() + + entry = Mock() + entry.entry_id = "entry-2" + entry.data = { + CONF_IP_ADDRESS: "192.168.1.100", + CONF_SERIAL_NUMBER: "SN123456", + CONF_CHECK_CODE: "CHECK123", + CONF_NAME: "Workshop Printer", + } + entry.options = {CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL} + entry.add_update_listener = Mock(return_value=Mock()) + entry.async_on_unload = Mock() + + client = Mock() + client.info.get = AsyncMock(return_value=Mock()) + client.cache_details = Mock(return_value=True) + client.send_product_command = AsyncMock(return_value=True) + + coordinator = Mock() + coordinator.async_config_entry_first_refresh = AsyncMock() + file_coordinator = Mock() + file_coordinator.async_refresh = AsyncMock() + + with ( + patch("custom_components.flashforge.FiveMClientConnectionOptions") as options_cls, + patch("custom_components.flashforge.FlashForgeClient", return_value=client), + patch("custom_components.flashforge.FlashForgeDataUpdateCoordinator", return_value=coordinator), + patch( + "custom_components.flashforge.FlashForgeFileListCoordinator", + return_value=file_coordinator, + ), + ): + assert await async_setup_entry(hass, entry) is True + + options_cls.assert_called_once_with(led_control_override=None) From cb73ac582282d9517ccb1f70d906766a3a48780e Mon Sep 17 00:00:00 2001 From: RedAces Date: Sun, 26 Jul 2026 09:49:09 +0200 Subject: [PATCH 09/10] file_print_probe.py: no more debug output while starting a print Correctly catch a connection error and exit early --- scripts/capability_probe.py | 16 ++++++++----- scripts/file_print_probe.py | 47 +++++++++++++++++++++++-------------- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/scripts/capability_probe.py b/scripts/capability_probe.py index 076d20e..7dac203 100644 --- a/scripts/capability_probe.py +++ b/scripts/capability_probe.py @@ -55,12 +55,16 @@ async def connect(ip: str, serial: str, check_code: str) -> FlashForgeClient: check_code=check_code, options=FiveMClientConnectionOptions(), ) - info = await client.info.get() - if info is None: - raise SystemExit(f"No answer from {ip} - check IP, serial, and check code.") - client.cache_details(info) - if not await client.send_product_command(): - raise SystemExit("Printer rejected the credentials (check code).") + try: + info = await client.info.get() + if info is None: + raise SystemExit(f"No answer from {ip} - check IP, serial, and check code.") + client.cache_details(info) + if not await client.send_product_command(): + raise SystemExit("Printer rejected the credentials (check code).") + except BaseException: + await client.dispose() + raise return client diff --git a/scripts/file_print_probe.py b/scripts/file_print_probe.py index 2f122e1..e575565 100644 --- a/scripts/file_print_probe.py +++ b/scripts/file_print_probe.py @@ -6,7 +6,8 @@ HTTP requests, the parsed file list, and the derived Material Station mappings can be verified on real hardware from Windows. -Read-only unless ``--print`` is passed. +Read-only unless ``--print`` is passed. ``--print`` reports the job start only - +the printer summary, file list, and the other dumps are skipped. Usage (from the repository root): @@ -83,12 +84,16 @@ async def connect(ip: str, serial: str, check_code: str) -> FlashForgeClient: check_code=check_code, options=FiveMClientConnectionOptions(), ) - info = await client.info.get() - if info is None: - raise SystemExit(f"No answer from {ip} - check IP, serial, and check code.") - client.cache_details(info) - if not await client.send_product_command(): - raise SystemExit("Printer rejected the credentials (check code).") + try: + info = await client.info.get() + if info is None: + raise SystemExit(f"No answer from {ip} - check IP, serial, and check code.") + client.cache_details(info) + if not await client.send_product_command(): + raise SystemExit("Printer rejected the credentials (check code).") + except BaseException: + await client.dispose() + raise return client @@ -256,7 +261,11 @@ async def main() -> None: help="ask /gcodeThumb for this stored file's preview image", ) parser.add_argument("--thumb-out", metavar="PATH", help="save the --thumb image here") - parser.add_argument("--print", dest="print_file", help="START a print of this file") + parser.add_argument( + "--print", + dest="print_file", + help="START a print of this file; suppresses the reports above", + ) parser.add_argument("--leveling", action="store_true", help="level the bed first") parser.add_argument("--yes", action="store_true", help="confirm the print start") args = parser.parse_args() @@ -272,25 +281,29 @@ async def main() -> None: try: info = await client.info.get() client.cache_details(info) - report_printer(client, info) + # Always needed: the entry carries the tool data the mappings derive + # from, and the machine info carries the slot colors. entries = await client.files.get_recent_file_list() entries = [e for e in entries or [] if e.gcode_file_name] - report_files(entries, info) - if args.raw: - await report_raw(client) + # --print keeps the output to the job itself; the reports would bury it. + if not args.print_file: + report_printer(client, info) + report_files(entries, info) + + if args.raw: + await report_raw(client) - if args.thumb: - await report_thumbnail(client, args.thumb, args.thumb_out) + if args.thumb: + await report_thumbnail(client, args.thumb, args.thumb_out) - if not args.print_file: print("\nRead-only run. Pass --print --yes to start a print.") return if not args.yes: print( - f"\nWould start '{args.print_file}' " + f"Would start '{args.print_file}' " f"(leveling={args.leveling}). Re-run with --yes to actually print." ) return @@ -298,7 +311,7 @@ async def main() -> None: entry = next( (e for e in entries if e.gcode_file_name == args.print_file), None ) - print(f"\nStarting '{args.print_file}' (leveling={args.leveling}) ...") + print(f"Starting '{args.print_file}' (leveling={args.leveling}) ...") await async_start_local_print( client, args.print_file, From aa26da52a136828d92fcbbd90f2b8aa8adf2218f Mon Sep 17 00:00:00 2001 From: RedAces Date: Sun, 26 Jul 2026 10:20:11 +0200 Subject: [PATCH 10/10] Add German translations --- .../flashforge/translations/de.json | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 custom_components/flashforge/translations/de.json diff --git a/custom_components/flashforge/translations/de.json b/custom_components/flashforge/translations/de.json new file mode 100644 index 0000000..5d3a17b --- /dev/null +++ b/custom_components/flashforge/translations/de.json @@ -0,0 +1,195 @@ +{ + "config": { + "step": { + "user": { + "title": "FlashForge-Drucker einrichten", + "description": "Wähle aus, wie der FlashForge-Drucker eingerichtet werden soll", + "data": { + "discovery_mode": "Einrichtungsart" + } + }, + "discovery": { + "title": "Suche nach FlashForge-Druckern", + "description": "Es wird im Netzwerk nach unterstützten Druckern im LAN-Modus gesucht ..." + }, + "select_printer": { + "title": "Drucker auswählen", + "description": "Mehrere Drucker gefunden. Wähle den Drucker aus, der eingerichtet werden soll.", + "data": { + "printer": "Drucker" + } + }, + "credentials": { + "title": "Check-Code eingeben", + "description": "Gib den Check-Code für {name} unter {ip} ein (Seriennummer: {serial}). Der Check-Code steht auf dem Display des Druckers in den Einstellungen zum LAN-Modus.", + "data": { + "check_code": "Check-Code" + } + }, + "manual": { + "title": "Manuelle Einrichtung", + "description": "Gib die Daten deines AD5X, Adventurer 5M, Adventurer 5M Pro, Creator 5 oder Creator 5 Pro manuell ein", + "data": { + "name": "Druckername", + "ip_address": "IP-Adresse", + "serial_number": "Seriennummer", + "check_code": "Check-Code" + } + }, + "reauth_confirm": { + "title": "FlashForge erneut authentifizieren", + "description": "Der Check-Code für {name} wird nicht mehr akzeptiert. Gib den aktuellen Check-Code aus dem LAN-Modus-Bildschirm des Druckers ein.", + "data": { + "check_code": "Check-Code" + } + }, + "reconfigure": { + "title": "FlashForge neu konfigurieren", + "description": "Verbindungsdaten für {name} aktualisieren (Seriennummer: {serial}).", + "data": { + "ip_address": "IP-Adresse", + "check_code": "Check-Code" + } + } + }, + "error": { + "cannot_connect": "Verbindung zum Drucker fehlgeschlagen. Bitte IP-Adresse und Zugangsdaten prüfen.", + "no_printers_found": "Keine FlashForge-Drucker im Netzwerk gefunden. Bitte die manuelle Einrichtung verwenden.", + "unsupported_printers_found": "Diese Integration unterstützt nur die Modelle AD5X, Adventurer 5M, Adventurer 5M Pro, Creator 5 und Creator 5 Pro.", + "discovery_failed": "Die Suche nach Druckern ist fehlgeschlagen. Bitte die manuelle Einrichtung verwenden.", + "unsupported_printer": "Diese Integration unterstützt nur die Modelle AD5X, Adventurer 5M, Adventurer 5M Pro, Creator 5 und Creator 5 Pro.", + "unknown": "Ein unerwarteter Fehler ist aufgetreten. Bitte erneut versuchen." + }, + "abort": { + "already_configured": "Dieser Drucker ist bereits eingerichtet", + "reconfigure_no_entry": "Der zu ändernde Konfigurationseintrag wurde nicht gefunden", + "reauth_successful": "Erneute Authentifizierung erfolgreich", + "reconfigure_successful": "Neukonfiguration erfolgreich" + } + }, + "options": { + "step": { + "init": { + "title": "FlashForge-Optionen", + "description": "Optionen für den FlashForge-Drucker einstellen", + "data": { + "scan_interval": "Abfrageintervall (Sekunden)", + "override_led_availability": "LED-Schalter immer anzeigen (Fähigkeitsprüfung des Druckers übergehen)", + "leveling_before_print": "Vor dem Druckstart das Bett nivellieren" + } + } + } + }, + "entity": { + "sensor": { + "machine_status": { + "name": "Druckerstatus", + "state": { + "ready": "Bereit", + "busy": "Beschäftigt", + "calibrating": "Kalibrierung", + "error": "Fehler", + "heating": "Heizt auf", + "printing": "Druckt", + "pausing": "Wird pausiert", + "paused": "Pausiert", + "cancelled": "Abgebrochen", + "completed": "Abgeschlossen", + "unknown": "Unbekannt" + } + }, + "nozzle_temperature": { "name": "Düsentemperatur" }, + "nozzle_target_temperature": { "name": "Düsen-Zieltemperatur" }, + "bed_temperature": { "name": "Betttemperatur" }, + "bed_target_temperature": { "name": "Bett-Zieltemperatur" }, + "print_progress": { "name": "Druckfortschritt" }, + "current_file": { "name": "Aktuelle Datei" }, + "current_layer": { "name": "Aktuelle Schicht" }, + "total_layers": { "name": "Schichten gesamt" }, + "elapsed_time": { "name": "Verstrichene Zeit" }, + "remaining_time": { "name": "Verbleibende Zeit" }, + "print_completion_time": { "name": "Voraussichtliches Druckende" }, + "filament_length": { "name": "Filamentlänge" }, + "filament_weight": { "name": "Filamentgewicht" }, + "print_speed": { "name": "Druckgeschwindigkeit" }, + "cooling_fan_speed": { "name": "Drehzahl Bauteillüfter" }, + "chamber_fan_speed": { "name": "Drehzahl Kammerlüfter" }, + "tvoc": { "name": "TVOC" }, + "z_offset": { "name": "Z-Offset" }, + "nozzle_size": { "name": "Düsendurchmesser" }, + "filament_type": { "name": "Filamenttyp" }, + "active_ifs_slot": { "name": "Aktiver Slot der Materialstation" }, + "lifetime_filament": { "name": "Filamentverbrauch gesamt" }, + "lifetime_runtime": { "name": "Betriebsdauer gesamt" }, + "firmware_version": { "name": "Firmware-Version" }, + "ip_address": { "name": "IP-Adresse" }, + "free_disk_space": { "name": "Freier Speicherplatz" }, + "error_code": { "name": "Fehlercode" }, + "tool_1_temperature": { "name": "Temperatur Werkzeug 1" }, + "tool_2_temperature": { "name": "Temperatur Werkzeug 2" }, + "tool_3_temperature": { "name": "Temperatur Werkzeug 3" }, + "tool_4_temperature": { "name": "Temperatur Werkzeug 4" }, + "tool_1_target_temperature": { "name": "Zieltemperatur Werkzeug 1" }, + "tool_2_target_temperature": { "name": "Zieltemperatur Werkzeug 2" }, + "tool_3_target_temperature": { "name": "Zieltemperatur Werkzeug 3" }, + "tool_4_target_temperature": { "name": "Zieltemperatur Werkzeug 4" }, + "chamber_temperature": { "name": "Kammertemperatur" }, + "chamber_target_temperature": { "name": "Kammer-Zieltemperatur" } + }, + "binary_sensor": { + "is_printing": { "name": "Druckt" }, + "is_online": { "name": "Online" }, + "has_error": { "name": "Fehler" }, + "is_paused": { "name": "Pausiert" }, + "door_open": { "name": "Tür offen" } + }, + "switch": { + "led": { "name": "LED" }, + "camera": { "name": "Kamera" } + }, + "button": { + "pause_print": { "name": "Druck pausieren" }, + "resume_print": { "name": "Druck fortsetzen" }, + "cancel_print": { "name": "Druck abbrechen" }, + "clear_status": { "name": "Status zurücksetzen" }, + "print_selected_file": { "name": "Ausgewählte Datei drucken" } + }, + "select": { + "local_file_selection": { "name": "Lokale Dateiauswahl" }, + "filtration_mode": { + "name": "Filtermodus", + "state": { + "off": "Aus", + "internal": "Intern", + "external": "Extern" + } + } + }, + "camera": { + "camera": { "name": "Kamera" } + }, + "image": { + "current_file_thumbnail": { "name": "Vorschau der aktuellen Datei" }, + "ifs_slot_1": { "name": "Materialstation Slot 1" }, + "ifs_slot_2": { "name": "Materialstation Slot 2" }, + "ifs_slot_3": { "name": "Materialstation Slot 3" }, + "ifs_slot_4": { "name": "Materialstation Slot 4" } + } + }, + "services": { + "print_file": { + "name": "Datei drucken", + "description": "Startet den Druck einer Datei, die bereits auf dem Drucker gespeichert ist.", + "fields": { + "file_name": { + "name": "Dateiname", + "description": "Name der Datei auf dem Drucker. Ohne Angabe wird die in der Entität „Lokale Dateiauswahl“ gewählte Datei gedruckt." + }, + "leveling_before_print": { + "name": "Bett vor dem Druck nivellieren", + "description": "Vor dem Druckstart das Bett nivellieren. Ohne Angabe gilt die Einstellung aus den Integrationsoptionen." + } + } + } + } +}