diff --git a/AGENTS.md b/AGENTS.md index fa5a1d2..20462a9 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.4.0` (in-flight; not yet tagged). Last published: `v1.3.3`. - **Supported printers:** `AD5X`, `Adventurer 5M`, `Adventurer 5M Pro`, `Creator 5`, and `Creator 5 Pro` only. -- **Entities shipped:** 56 total (38 sensors, 5 binary sensors, 2 switches (the camera switch is not created on the Creator 5 series), 1 select, 4 buttons, 1 camera, 5 images — the g-code thumbnail plus 4 Material Station slot color swatches). +- **Entities shipped:** 58 total (38 sensors, 5 binary sensors, 2 switches (the camera switch is not created on the Creator 5 series), 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 Local File Selection entity). - **Key dependency:** `flashforge-python-api>=1.3.4` (see sibling repo `ff-5mp-api-py`). - **Also shipped:** a Lovelace card (`frontend/ff-job-card.js`) for browsing the printer's files, matching materials, and starting prints, served and registered by the integration itself and backed by four websocket commands (`websocket.py`, `job.py`). - **Languages:** English and German, for both the integration (`translations/`) and the card (`frontend/translations/`). German contributed by @RedAces. @@ -78,8 +79,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); 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. 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 d24b712..6ad0471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The LED switch is no longer greyed out on every printer.** The "Always show LED switch" option was passed to the library as `led_control_override` using its unset value `False`. That parameter is tri-state — `None` means "no override", `True` forces the capability on, and `False` forces it **off** — so with the option switched off, which is the default, the integration overrode the printer's own correct capability report and pinned `client.led_control` to `False` on every model. The switch stayed unavailable, and the library additionally refused `set_led_on()` / `set_led_off()` internally, which made enabling the override look like the only way to get a working switch: `True` was the only value that got past the veto. The option now sends `None` when off and `True` only when the user asks for it, which is what it was always meant to do. Reported and diagnosed on a Creator 5 Pro, where `/product` correctly reports `lightCtrlState: 1` all along. Refs [#17](https://github.com/GhostTypes/ff-5mp-hass/issues/17). +### 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._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 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. + +### 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. + +### 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 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 9d2f7dd..1d672aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,8 @@ Guidance for AI coding assistants working in this repository. - Ships a **Lovelace card** (`frontend/ff-job-card.js`) for starting local prints with material matching, served and registered by the integration itself — no separate HACS entry, no Lovelace resource step. - **Languages: English + German**, for the integration (`translations/`) and independently for the card (`frontend/translations/`). Both follow the user's HA profile language. German contributed by @RedAces in PR #19. - 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 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). @@ -51,7 +52,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. @@ -62,11 +63,12 @@ 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 (Adventurer 5M Pro / Creator 5 Pro only — gated on `is_pro OR is_creator5_pro`). - Pause / resume / cancel / clear-status buttons with post-action refresh. + - 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`. - **Starting local prints** from the job card: the printer's ten most recent files (all models), with per-file metadata and per-tool material data where the model reports it (AD5X / Creator 5 series), plus the tool-to-slot material matching dialog those models need. Ported from FlashForgeUI-Electron's job picker + material-matching dialog. - **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 @@ -79,12 +81,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` – 38 sensor entities (operational + diagnostic). `SENSORS` is composed of `_BASE_SENSORS + TOOLHEAD_SENSORS + CHAMBER_SENSORS` (per-toolhead and heated-chamber sensors are gated on the Creator 5 series). Modify the tuples, translations, and docs together when changing sensors. - `binary_sensor.py` – 5 machine-state binary sensors (printing, online, error, paused, door-open). `door_open` is availability-gated on `has_door_sensor` (Creator 5 Pro only). - `switch.py` – LED switch with client capability check (capability check can be overridden via options) and the camera switch. Descriptions carry both an `availability_fn` (greys the entity out; use when the printer may report the feature later) and a `supported_fn` (skips creating it entirely; use when the model's API cannot perform the action at all — the Creator 5 camera switch is inert, so it is never created there). -- `select.py` – Filtration mode select (Off / Internal / External; availability gated on `is_pro OR is_creator5_pro`, i.e. Adventurer 5M Pro / Creator 5 Pro). -- `button.py` – Pause / resume / cancel / clear-status commands; request a refresh after each action. +- `select.py` – Filtration mode select (Off / Internal / External; availability gated on `is_pro OR is_creator5_pro`, i.e. Adventurer 5M Pro / Creator 5 Pro) and the Local File Selection entity (`FlashForgeFileSelect`, options = the printer's file list, per-file metadata in `extra_state_attributes` via `file_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`). - `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. - `job.py` – Local print jobs: normalizing `/gcodeList` entries and Material Station slots for the card, the material-matching rules, and the per-model print-start dispatch (Creator 5 → `start_creator5_job`; AD5X → multi- or single-color; 5M → `print_local_file`). **This module is the authority on matching, not the card.** @@ -95,6 +99,11 @@ Treat this file as the living source of truth for workflows and expectations—u - `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. - `strings.json` / `translations/.json` – Home Assistant-side UI copy (entities, config flow, errors). Keep `strings.json` and `translations/en.json` synchronized; `tests/unit/test_translations.py` enforces it. Every entity carries a `translation_key`; `name`s never set manually on entities. Shipping: English, German (`de.json`, contributed by @RedAces). +- `coordinator.py` – also hosts `FlashForgeFileListCoordinator`, polling `files.get_recent_file_list()` on the slower `FILE_LIST_SCAN_INTERVAL`; it holds the file selected for printing and never closes the shared client. +- `select.py` – besides the filtration select, the Local File Selection entity (`FlashForgeFileSelect`, options = the printer's file list, per-file metadata via `file_attributes()`), and it registers the `flashforge.print_file` entity service. +- `button.py` – besides the job-control buttons, `FlashForgePrintSelectedFileButton`. Its availability follows reachability only; a missing selection is a `ServiceValidationError` at press time. +- `print_job.py` – the entity path's per-model print-start dispatch and Material Station mapping. **Overlaps `job.py`** (the card's copy of the same rules); see the guard rails. +- `services.yaml` – service definition for `flashforge.print_file` (keep in sync with the `services` block in `strings.json`). ## External Dependencies & Linked Projects - **flashforge-python-api (ff-5mp-api-py)** – Located at `C:\Users\coper\Documents\GitHub\1flashforge_printers\ff-5mp-api-py`. Supplies the async HTTP client, discovery helpers, models (`FFMachineInfo`, `MachineState`, etc.). Do not duplicate API logic in this repository—import from the library. @@ -296,6 +305,9 @@ pytest tests/unit/test_sensor_value_functions.py -v - `tests/unit/test_translations.py` – every translation file against English: key sets, `{placeholder}` sets, plural pairs, and that no card string is orphaned - `tests/unit/test_job.py` – material matching rules, auto-match suggestions, per-model print-start dispatch - `tests/unit/test_websocket.py` – the job card's websocket commands, including the refusal to start a material-station print without mappings +- `tests/unit/test_file_list_coordinator.py` – local file list fetch, filtering, and error paths +- `tests/unit/test_print_job.py` – the entity path's dispatch and mapping derivation +- `tests/unit/test_print_file_entities.py` – Local File Selection select + print button behavior - `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 @@ -307,10 +319,16 @@ 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` – Local File Selection select + print button behavior +- `tests/unit/test_capability_gated_entities.py` – deferred entity creation when a capability reports in late (slot images + gated sensors) +- `tests/unit/test_config_flow_error_reporting.py` – `cannot_connect` / `invalid_auth` / `invalid_response` mapping **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) @@ -353,8 +371,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, door-open (Creator 5 Pro only). - Switch: LED (may show unavailable on unsupported models unless override is enabled). - - Select: filtration mode — Off / Internal / External (Adventurer 5M Pro / Creator 5 Pro only). - - Buttons: pause, resume, cancel, clear status. + - Select: filtration mode — Off / Internal / External (Adventurer 5M Pro / Creator 5 Pro 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 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 / Creator 5 series): four Material Station slot swatches (`image.*_ifs_slot_1..4`) showing material color + label, "EMPTY" tile for unloaded slots. @@ -367,10 +386,12 @@ 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; `--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_matl_station`, chamber/door/camera). Use it whenever an entity is unexpectedly greyed out or missing, 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 -- **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, 40 = Creator 5, 41 = Creator 5 Pro). The integration enforces this in TWO places that should both stay in sync: - `config_flow.py` `_is_supported_detail()` reads the raw `/detail` payload during pairing and rejects PIDs not in `SUPPORTED_PIDS = {35, 36, 38, 40, 41}`. This is the early gate, and "early" is load-bearing: it consumes `client.info.get_detail_raw()` (the undecoded JSON dict), so it runs before **any** validation, not just before `FFMachineInfo` parsing. Until v1.3.4 it read `pid` off a parsed `FFPrinterDetail`, which meant a supported Creator 5 could be turned away because an unrelated field (`chamberTemp: -108`) failed validation first — see issue #18. Never move this gate back onto a parsed model. @@ -378,6 +399,8 @@ pytest tests/unit/test_sensor_value_functions.py -v - 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. - **The job card is an untrusted client** – Every matching rule enforced in `ff-job-card.js` is enforced again in `job.py`, which re-derives materials and colors from the file list and the live station report. The JS copy exists to explain the rule as the user clicks; the Python copy is what decides. A websocket client that skips the dialog entirely must not be able to start a material-station print without mappings — `ws_start_job` refuses it. When you change a rule, change both, and add the test to `tests/unit/test_job.py`. - **File listing is capped at ten files, deliberately** – `/gcodeList` returns the ten most recent files and nothing more; the full local listing exists only over TCP `M661`, which this integration does not speak. This will read as a bug report eventually; it is the documented cost of the HTTP-only policy, not an oversight. The 5M / 5M Pro additionally report names only (no `gcodeListDetail`), so they get no metadata and no matching step. +- **Capability flags: never gate on a raw `/detail` passthrough** – Firmware omits fields that don't apply to a model, so an absent value means "not reported", and a `None`-able flag invites a consumer to read it as "no". A Creator 5 Pro (pid 41) leaves `hasMatlStation` out of `/detail` entirely while `matlStationInfo` reports four loaded slots, which hid the Material Station entities on exactly the models v1.3.0 added them for. Gate on a derived, always-concrete capability — `FFMachineInfo.has_matl_station` (library ≥1.3.2 derives it from the slot data) — and verify with `scripts/capability_probe.py`, which dumps the untouched `/detail` and `/product` payloads next to the derived flags, before trusting a single field. See the fuller rules in `AGENTS.md`. +- **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. - **"Could not read the answer" is not "could not reach the printer"** – The library returns `None` when a request never got through and raises `FlashForgeResponseError` when the printer answered with a payload it could not parse. Keep the two apart all the way to the user: the config flow maps the exception to `invalid_response` (never `cannot_connect`), and `__init__.py` / `coordinator.py` log it with wording that sends the user to the issue tracker rather than to their router. Collapsing them is what made issue #18 take three releases — the printer was reachable and the credentials were correct the entire time, but every message on offer said otherwise. - **Never constrain the *range* of data received from the printer** – This applies to the API library, but the integration is what breaks when it is violated. Pydantic validates a model all-or-nothing, so a `ge=`/`le=` on any one of ~50 `/detail` fields can fail the whole response and take every entity offline. Firmware also signals absent hardware with out-of-band sentinels (`chamberTemp: -108`) rather than by omitting the field, so "impossible" values are normal. Inbound models validate types only; range constraints belong on outbound command models, where a bad value is our own bug. If a new field needs bounds, normalize it in the parser, don't reject it. diff --git a/README.md b/README.md index 5be482c..f0b34eb 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,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. | @@ -266,6 +266,7 @@ | Entity | Description | Options | Availability | |--------|-------------|---------|--------------| | `select.flashforge_filtration_mode` | Control filtration system | Off, Internal, External | 5M Pro / Creator 5 Pro | +| `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 | @@ -283,6 +284,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_local_file_selection` | @@ -362,6 +364,39 @@ Keep `_one` / `_other` pairs together (`tools_one`, `tools_other`) — they are Then run `pytest tests/unit/test_translations.py`, which checks both files against English for missing or unknown keys, mismatched placeholders, and incomplete plurals. No build step and no JavaScript changes are involved. PRs welcome. +
+

Starting Prints — Entities and the Service

+
+ +Alongside the card, the same job can be started from entities, which is what +automations and scripts need — a card cannot be triggered by one. + +`select.flashforge_local_file_selection` lists the files on the printer and records which +one to print; picking one starts nothing. `button.flashforge_print_selected_file` starts it, +and reports an error when nothing is selected. + +```yaml +action: flashforge.print_file +target: + 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 +``` + +`file_name` accepts any file on the printer, including ones outside the ten the API lists. +Whatever metadata the printer reports per file is on the select entity for templates: + +```yaml +{{ state_attr('select.flashforge_local_file_selection', 'files') }} +# AD5X: [{'name': 'benchy.3mf', 'printing_time': 3600, 'filament_weight': 25.5, ...}, ...] +# Creator 5 / 5 Pro: [{'name': 'benchy.3mf'}, ...] +``` + +Keys the printer does not report are omitted rather than reported as `0`/`false`. The file +list refreshes every 60 seconds; `homeassistant.update_entity` on the select forces it. + +

Usage Examples

diff --git a/custom_components/flashforge/__init__.py b/custom_components/flashforge/__init__.py index 369dc30..3cee836 100644 --- a/custom_components/flashforge/__init__.py +++ b/custom_components/flashforge/__init__.py @@ -23,7 +23,7 @@ DEFAULT_SCAN_INTERVAL, DOMAIN, ) -from .coordinator import FlashForgeDataUpdateCoordinator +from .coordinator import FlashForgeDataUpdateCoordinator, FlashForgeFileListCoordinator from .card import async_register_frontend from .util import async_close_flashforge_client from .websocket import async_register_websocket_commands @@ -140,9 +140,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, } @@ -162,8 +172,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..6f8c8da 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,68 @@ 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 Local File Selection 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 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 + ) + + async def async_press(self) -> None: + """Start printing the selected file.""" + 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 Local File Selection " + "entity first" + ) + + 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 d8d4ada..5507594 100644 --- a/custom_components/flashforge/config_flow.py +++ b/custom_components/flashforge/config_flow.py @@ -20,8 +20,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, @@ -596,6 +598,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 ccf6c1d..0095d4b 100644 --- a/custom_components/flashforge/coordinator.py +++ b/custom_components/flashforge/coordinator.py @@ -5,12 +5,12 @@ import logging from flashforge import FlashForgeClient, FlashForgeResponseError -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__) @@ -88,3 +88,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..22a5a0f --- /dev/null +++ b/custom_components/flashforge/print_job.py @@ -0,0 +1,187 @@ +"""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. + + 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) + 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 - 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 + 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..24bfcc8 100644 --- a/custom_components/flashforge/select.py +++ b/custom_components/flashforge/select.py @@ -7,20 +7,57 @@ 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 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, +} + + +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): @@ -54,6 +91,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 Local File Selection " + f"entity, 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 +114,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 +194,97 @@ 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 = "local_file_selection" + _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 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}_local_file_selection" + 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": [file_attributes(entry) 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 + # 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, + 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 4f800fc..0f2dda0 100644 --- a/custom_components/flashforge/strings.json +++ b/custom_components/flashforge/strings.json @@ -76,7 +76,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" } } } @@ -152,9 +153,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": { + "local_file_selection": { "name": "Local File Selection" }, "filtration_mode": { "name": "Filtration Mode", "state": { @@ -175,6 +178,22 @@ "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 picked on the Local File Selection entity." + }, + "leveling_before_print": { + "name": "Level bed before print", + "description": "Run bed leveling before the print starts. Defaults to the integration option." + } + } + } + }, "notifications": { "card_reload": { "title": "FlashForge job card is ready", diff --git a/custom_components/flashforge/translations/de.json b/custom_components/flashforge/translations/de.json index dc892a9..6b86722 100644 --- a/custom_components/flashforge/translations/de.json +++ b/custom_components/flashforge/translations/de.json @@ -76,7 +76,8 @@ "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)" + "override_led_availability": "LED-Schalter immer anzeigen (Fähigkeitsprüfung des Druckers übergehen)", + "leveling_before_print": "Vor dem Druckstart das Bett nivellieren" } } } @@ -248,6 +249,9 @@ }, "clear_status": { "name": "Status zurücksetzen" + }, + "print_selected_file": { + "name": "Ausgewählte Datei drucken" } }, "select": { @@ -258,6 +262,9 @@ "internal": "Intern", "external": "Extern" } + }, + "local_file_selection": { + "name": "Lokale Dateiauswahl" } }, "camera": { @@ -285,8 +292,24 @@ }, "notifications": { "card_reload": { - "title": "Die FlashForge-Druckauftragskarte ist bereit", - "message": "Die Karte „FlashForge Print Job“ wurde soeben installiert oder aktualisiert. Lade diese Seite neu (Strg+R, auf dem Mac Cmd+R), damit sie geladen wird – bis dahin erscheint die Karte nicht in der Kartenauswahl und Dashboards, die sie bereits verwenden, zeigen einen Fehler. Das ist nur einmal pro Update nötig." + "title": "FlashForge-Auftragskarte ist bereit", + "message": "Die Karte „FlashForge Print Job“ wurde gerade installiert oder aktualisiert. Lade diese Seite neu (Strg+R, auf dem Mac Cmd+R), damit sie erkannt wird — bis dahin erscheint die Karte nicht in der Kartenauswahl, und Dashboards, die sie bereits verwenden, zeigen einen Fehler. Das ist nur einmal pro Aktualisierung nötig." + } + }, + "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." + } + } } } } diff --git a/custom_components/flashforge/translations/en.json b/custom_components/flashforge/translations/en.json index 4f800fc..cb769ab 100644 --- a/custom_components/flashforge/translations/en.json +++ b/custom_components/flashforge/translations/en.json @@ -76,7 +76,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" } } } @@ -99,60 +100,159 @@ "unknown": "Unknown" } }, - "nozzle_temperature": { "name": "Nozzle Temperature" }, - "nozzle_target_temperature": { "name": "Nozzle Target Temperature" }, - "bed_temperature": { "name": "Bed Temperature" }, - "bed_target_temperature": { "name": "Bed Target Temperature" }, - "print_progress": { "name": "Print Progress" }, - "current_file": { "name": "Current File" }, - "current_layer": { "name": "Current Layer" }, - "total_layers": { "name": "Total Layers" }, - "elapsed_time": { "name": "Elapsed Time" }, - "remaining_time": { "name": "Remaining Time" }, - "print_completion_time": { "name": "Print Completion Time" }, - "filament_length": { "name": "Filament Length" }, - "filament_weight": { "name": "Filament Weight" }, - "print_speed": { "name": "Print Speed" }, - "cooling_fan_speed": { "name": "Cooling Fan Speed" }, - "chamber_fan_speed": { "name": "Chamber Fan Speed" }, - "tvoc": { "name": "TVOC" }, - "z_offset": { "name": "Z-Axis Offset" }, - "nozzle_size": { "name": "Nozzle Size" }, - "filament_type": { "name": "Filament Type" }, - "active_ifs_slot": { "name": "Active Material Station Slot" }, - "lifetime_filament": { "name": "Lifetime Filament Usage" }, - "lifetime_runtime": { "name": "Lifetime Runtime" }, - "firmware_version": { "name": "Firmware Version" }, - "ip_address": { "name": "IP Address" }, - "free_disk_space": { "name": "Free Disk Space" }, - "error_code": { "name": "Error Code" }, - "tool_1_temperature": { "name": "Tool 1 Temperature" }, - "tool_2_temperature": { "name": "Tool 2 Temperature" }, - "tool_3_temperature": { "name": "Tool 3 Temperature" }, - "tool_4_temperature": { "name": "Tool 4 Temperature" }, - "tool_1_target_temperature": { "name": "Tool 1 Target Temperature" }, - "tool_2_target_temperature": { "name": "Tool 2 Target Temperature" }, - "tool_3_target_temperature": { "name": "Tool 3 Target Temperature" }, - "tool_4_target_temperature": { "name": "Tool 4 Target Temperature" }, - "chamber_temperature": { "name": "Chamber Temperature" }, - "chamber_target_temperature": { "name": "Chamber Target Temperature" } + "nozzle_temperature": { + "name": "Nozzle Temperature" + }, + "nozzle_target_temperature": { + "name": "Nozzle Target Temperature" + }, + "bed_temperature": { + "name": "Bed Temperature" + }, + "bed_target_temperature": { + "name": "Bed Target Temperature" + }, + "print_progress": { + "name": "Print Progress" + }, + "current_file": { + "name": "Current File" + }, + "current_layer": { + "name": "Current Layer" + }, + "total_layers": { + "name": "Total Layers" + }, + "elapsed_time": { + "name": "Elapsed Time" + }, + "remaining_time": { + "name": "Remaining Time" + }, + "print_completion_time": { + "name": "Print Completion Time" + }, + "filament_length": { + "name": "Filament Length" + }, + "filament_weight": { + "name": "Filament Weight" + }, + "print_speed": { + "name": "Print Speed" + }, + "cooling_fan_speed": { + "name": "Cooling Fan Speed" + }, + "chamber_fan_speed": { + "name": "Chamber Fan Speed" + }, + "tvoc": { + "name": "TVOC" + }, + "z_offset": { + "name": "Z-Axis Offset" + }, + "nozzle_size": { + "name": "Nozzle Size" + }, + "filament_type": { + "name": "Filament Type" + }, + "active_ifs_slot": { + "name": "Active Material Station Slot" + }, + "lifetime_filament": { + "name": "Lifetime Filament Usage" + }, + "lifetime_runtime": { + "name": "Lifetime Runtime" + }, + "firmware_version": { + "name": "Firmware Version" + }, + "ip_address": { + "name": "IP Address" + }, + "free_disk_space": { + "name": "Free Disk Space" + }, + "error_code": { + "name": "Error Code" + }, + "tool_1_temperature": { + "name": "Tool 1 Temperature" + }, + "tool_2_temperature": { + "name": "Tool 2 Temperature" + }, + "tool_3_temperature": { + "name": "Tool 3 Temperature" + }, + "tool_4_temperature": { + "name": "Tool 4 Temperature" + }, + "tool_1_target_temperature": { + "name": "Tool 1 Target Temperature" + }, + "tool_2_target_temperature": { + "name": "Tool 2 Target Temperature" + }, + "tool_3_target_temperature": { + "name": "Tool 3 Target Temperature" + }, + "tool_4_target_temperature": { + "name": "Tool 4 Target Temperature" + }, + "chamber_temperature": { + "name": "Chamber Temperature" + }, + "chamber_target_temperature": { + "name": "Chamber Target Temperature" + } }, "binary_sensor": { - "is_printing": { "name": "Printing" }, - "is_online": { "name": "Online" }, - "has_error": { "name": "Error" }, - "is_paused": { "name": "Paused" }, - "door_open": { "name": "Door Open" } + "is_printing": { + "name": "Printing" + }, + "is_online": { + "name": "Online" + }, + "has_error": { + "name": "Error" + }, + "is_paused": { + "name": "Paused" + }, + "door_open": { + "name": "Door Open" + } }, "switch": { - "led": { "name": "LED" }, - "camera": { "name": "Camera" } + "led": { + "name": "LED" + }, + "camera": { + "name": "Camera" + } }, "button": { - "pause_print": { "name": "Pause Print" }, - "resume_print": { "name": "Resume Print" }, - "cancel_print": { "name": "Cancel Print" }, - "clear_status": { "name": "Clear Status" } + "pause_print": { + "name": "Pause Print" + }, + "resume_print": { + "name": "Resume Print" + }, + "cancel_print": { + "name": "Cancel Print" + }, + "clear_status": { + "name": "Clear Status" + }, + "print_selected_file": { + "name": "Print Selected File" + } }, "select": { "filtration_mode": { @@ -162,17 +262,32 @@ "internal": "Internal", "external": "External" } + }, + "local_file_selection": { + "name": "Local File Selection" } }, "camera": { - "camera": { "name": "Camera" } + "camera": { + "name": "Camera" + } }, "image": { - "current_file_thumbnail": { "name": "Current File Thumbnail" }, - "ifs_slot_1": { "name": "Material Station Slot 1" }, - "ifs_slot_2": { "name": "Material Station Slot 2" }, - "ifs_slot_3": { "name": "Material Station Slot 3" }, - "ifs_slot_4": { "name": "Material Station Slot 4" } + "current_file_thumbnail": { + "name": "Current File Thumbnail" + }, + "ifs_slot_1": { + "name": "Material Station Slot 1" + }, + "ifs_slot_2": { + "name": "Material Station Slot 2" + }, + "ifs_slot_3": { + "name": "Material Station Slot 3" + }, + "ifs_slot_4": { + "name": "Material Station Slot 4" + } } }, "notifications": { @@ -180,5 +295,21 @@ "title": "FlashForge job card is ready", "message": "The FlashForge Print Job card was just installed or updated. Reload this page (Ctrl+R, or Cmd+R on a Mac) to pick it up - until you do, the card will not appear in the card picker and dashboards already using it will show an error. This is only needed once per update." } + }, + "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 picked on the Local File Selection 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/requirements-test.txt b/requirements-test.txt index ee1dd49..f04cca9 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -9,9 +9,12 @@ 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.3.3 +flashforge-python-api>=1.3.4 # Network interface detection (required by discovery) netifaces>=0.11.0 diff --git a/scripts/capability_probe.py b/scripts/capability_probe.py new file mode 100644 index 0000000..c369048 --- /dev/null +++ b/scripts/capability_probe.py @@ -0,0 +1,219 @@ +#!/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 + + + +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(), + ) + 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 + + +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 {getattr(info, 'has_matl_station', None)} (Material Station entities; derived by the library, not a raw /detail field)") + 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 new file mode 100644 index 0000000..e575565 --- /dev/null +++ b/scripts/file_print_probe.py @@ -0,0 +1,328 @@ +#!/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. ``--print`` reports the job start only - +the printer summary, file list, and the other dumps are skipped. + +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 ... --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. +""" + +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(), + ) + 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 + + +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" 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( + 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 _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: + print(" - upload a file to the printer first.") + return + + 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 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( + 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: not needed (file does not use the material station)") + 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_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. + + 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( + "--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; 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() + + 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) + + # 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] + + # --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) + + print("\nRead-only run. Pass --print --yes to start a print.") + return + + if not args.yes: + print( + f"Would 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"Starting '{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()) diff --git a/tests/ha_mocks.py b/tests/ha_mocks.py index 996579f..5c7c6ec 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): @@ -48,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.""" @@ -61,6 +69,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].""" @@ -97,6 +109,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.""" @@ -138,6 +158,8 @@ class SwitchEntity(Entity): class ImageEntity(Entity): """Stub for homeassistant.components.image.ImageEntity.""" + _attr_image_last_updated = None + def __init__(self, hass=None, *args, **kwargs): # The real ImageEntity takes `hass` positionally and sets up verify_ssl # / access-token plumbing the tests do not exercise. @@ -507,6 +529,8 @@ class StaticPathConfig: 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 exceptions_module.HomeAssistantError = HomeAssistantError 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..a6e9790 --- /dev/null +++ b/tests/unit/test_print_file_entities.py @@ -0,0 +1,264 @@ +"""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, ServiceValidationError + + +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 +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(): + """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() + # 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 + + +@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_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 True + + file_coordinator.selected_file = "benchy.3mf" + assert button.available is True + + file_coordinator.selected_file = "gone.gcode" + assert button.available is True + + +@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_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(): + """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..ba25f3c --- /dev/null +++ b/tests/unit/test_print_job.py @@ -0,0 +1,277 @@ +"""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 + # 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 +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 9e42a18..c4befba 100644 --- a/tests/unit/test_setup_entry.py +++ b/tests/unit/test_setup_entry.py @@ -55,12 +55,19 @@ async def _run_setup(entry_options: dict): 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) @@ -73,6 +80,7 @@ async def _run_setup(entry_options: dict): "options_sentinel": options_sentinel, "machine_info": machine_info, "coordinator": coordinator, + "file_coordinator": file_coordinator, } @@ -105,6 +113,13 @@ async def test_async_setup_entry_forces_led_capability_when_the_user_asks(): mocks["hass"].config_entries.async_forward_entry_setups.assert_awaited_once() mocks["entry"].async_on_unload.assert_called_once() assert mocks["hass"].data[DOMAIN][mocks["entry"].entry_id]["client"] is mocks["client"] + # The file list is fetched with the non-raising refresh, so a file list + # hiccup cannot block setup. + mocks["file_coordinator"].async_refresh.assert_awaited_once() + assert ( + mocks["hass"].data[DOMAIN][mocks["entry"].entry_id]["file_coordinator"] + is mocks["file_coordinator"] + ) @pytest.mark.unit