From 54106570d9f55d1c93022d918b2143d573070c17 Mon Sep 17 00:00:00 2001 From: RedAces Date: Sat, 8 Aug 2026 11:49:22 +0200 Subject: [PATCH] The creator 5 pro goes into "pause" mode if a clog is detected. In error code it returns "E0163", but this code is undocumented. Fix the error-detection to include an error code returned from the machine. add a printer_state.py to read the current state of the printer for debugging --- CHANGELOG.md | 8 + custom_components/flashforge/binary_sensor.py | 11 +- scripts/printer_state.py | 218 ++++++++++++++++++ .../test_binary_sensor_value_functions.py | 34 +++ 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 scripts/printer_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c6944c2..6d5f4ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The job card never registered on Home Assistant 2026.7.** The frontend replaces `window.customElements` with its own scoped registry while it boots, and `add_extra_js_url` puts the card in the document — so it ran *before* that swap and defined its elements in the registry the frontend then stopped consulting. Nothing was logged: `customElements.define()` succeeded, it just went to the wrong place. The symptoms all pointed elsewhere — the module was served with `200` and `text/javascript`, the console banner appeared, `window.customCards` listed the card (that lives on `window`, not on the registry), and yet the picker did not offer it and dashboards using it showed *"custom element doesn't exist"*. Re-importing the identical file after boot worked and did **not** raise "already defined", which is what finally identified two separate registries. The card now defines its elements through a guarded helper and repeats the registration if the frontend exchanges the registry; a browser that never swaps registers exactly once. Reported and diagnosed on HA 2026.7.4 with Firefox; the workaround until now was adding the card as a Lovelace resource, which loads after the boot and was never affected. +## [Unreleased] + +### Fixed + +- **The Error binary sensor stayed off while the printer sat on a detected clog.** It asked only whether the machine state was `ERROR`, but a Creator 5 Pro that detects a clog does not enter that state: it pauses the print and fills `errorCode`. Observed live at 89 % of a print (pid 41, firmware 1.9.5) — the printer displayed *"Clog detected"*, `/detail` reported `status: "pause"` with `errorCode: "E0163"`, and Home Assistant showed no problem on any channel: the Error sensor was `off`, the Paused sensor was `off` (the raw `"pause"` did not map to `PAUSED` — fixed in `flashforge-python-api`), and Machine Status read `unknown`. `binary_sensor._error` now also turns on when the printer reports a non-empty error code, whatever state it reports alongside it. + + The **Error Code** sensor, which carries the code itself, is disabled by default and has to be enabled per entity — worth considering as a default, since on this model it is the only channel that says *why* a print stopped. + ## [1.4.0] - 2026-07-31 ### Added diff --git a/custom_components/flashforge/binary_sensor.py b/custom_components/flashforge/binary_sensor.py index a5e8eea..9b9402f 100644 --- a/custom_components/flashforge/binary_sensor.py +++ b/custom_components/flashforge/binary_sensor.py @@ -52,7 +52,16 @@ class FlashForgeBinarySensorEntityDescription(BinarySensorEntityDescription): translation_key="has_error", device_class=BinarySensorDeviceClass.PROBLEM, icon="mdi:alert-circle", - value_fn=lambda data: data.machine_state == MachineState.ERROR, + # Two independent signals, and the printer does not always use the one + # this sensor used to read. A Creator 5 Pro that detects a clog does not + # enter the ERROR state: it pauses the print and fills `errorCode` + # (observed live as `E0163` at 89% of a print, while `status` read + # "pause"). Asking only about the state left the problem sensor silent + # for the whole outage - the one entity whose job is to say that + # something needs attention. + value_fn=lambda data: ( + data.machine_state == MachineState.ERROR or bool(data.error_code) + ), ), FlashForgeBinarySensorEntityDescription( key="is_paused", diff --git a/scripts/printer_state.py b/scripts/printer_state.py new file mode 100644 index 0000000..78722ec --- /dev/null +++ b/scripts/printer_state.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Watch what the printer reports, and what the integration makes of it. + +Written for a specific failure and kept for the next one: a Creator 5 Pro +detects a clog, pauses, and the Machine Status sensor goes to "unknown". The +sensor is an enum fed by the library's `MachineState`, which maps a fixed set of +raw status strings and falls back to UNKNOWN for the rest - so an unmapped value +is indistinguishable from a printer that reported nothing at all. + +This prints the raw status beside the mapped one and flags anything unmapped, so +the cause is visible while it happens instead of being reconstructed afterwards. +The library also logs `Unknown machine status received` for each occurrence; if +you have the Home Assistant log, grep it for that first. + +Standard library only: no dependency on the integration, its virtualenv, or the +API library, so this can be dropped onto any machine that can reach the printer. + +Usage (from the repository root): + + python scripts/printer_state.py --ip 192.168.1.50 --serial SN123 --check-code ABCD + python scripts/printer_state.py --ha-config /config # read credentials from HA + python scripts/printer_state.py --watch # poll until Ctrl+C + python scripts/printer_state.py --watch --log clog.jsonl # and keep every sample + python scripts/printer_state.py --raw # full /detail payload + +Credentials may also come from the environment: FF_IP, FF_SERIAL, FF_CHECK_CODE. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +import urllib.request +from datetime import datetime +from pathlib import Path + +HTTP_PORT = 8898 + +# Mirrored from flashforge/api/controls/info.py `_get_machine_state`. Anything +# the printer reports that is not in here becomes MachineState.UNKNOWN, which +# Home Assistant renders as "unknown". Keep this list in step with the library; +# a value that appears here but not there is exactly the bug this script hunts. +KNOWN_STATUS = { + "ready": "READY", + "busy": "BUSY", + "calibrate_doing": "CALIBRATING", + "error": "ERROR", + "heating": "HEATING", + "printing": "PRINTING", + "pausing": "PAUSING", + "pause": "PAUSED", + "paused": "PAUSED", + "cancel": "CANCELLED", + "completed": "COMPLETED", + "downloading": "BUSY", +} + + +def credentials_from_ha(config_dir: str) -> dict[str, str]: + """Read ip / serial / check code out of Home Assistant's config entry. + + Saves keeping a second copy of the check code, and cannot drift out of step + with the integration, because it is the same value the integration uses. + """ + store = Path(config_dir) / ".storage" / "core.config_entries" + try: + data = json.loads(store.read_text(encoding="utf-8")) + except OSError as err: + sys.exit(f"Could not read {store}: {err}") + + for entry in data.get("data", {}).get("entries", []): + if entry.get("domain") == "flashforge": + payload = entry["data"] + return { + "ip": payload["ip_address"], + "serial": payload["serial_number"], + "check_code": payload["check_code"], + "name": entry.get("title") or "FlashForge", + } + sys.exit(f"No FlashForge config entry found in {store}") + + +def fetch_detail(creds: dict[str, str], timeout: float = 10.0) -> dict: + """POST /detail and return the parsed payload.""" + payload = json.dumps( + {"serialNumber": creds["serial"], "checkCode": creds["check_code"]} + ).encode() + request = urllib.request.Request( + f"http://{creds['ip']}:{HTTP_PORT}/detail", + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read()) + + +def summarize(detail: dict) -> dict: + """Pull out the fields that matter when the printer stops doing what it should.""" + d = detail.get("detail", {}) if isinstance(detail, dict) else {} + status = d.get("status", "") + return { + "status": status, + "mapped": KNOWN_STATUS.get(str(status).lower(), "UNKNOWN"), + "known": str(status).lower() in KNOWN_STATUS, + "error_code": d.get("errorCode", ""), + "file": d.get("printFileName", ""), + "progress": d.get("printProgress"), + "layer": d.get("printLayer"), + "layers": d.get("targetPrintLayer"), + "nozzle_temps": d.get("nozzleTemps"), + "bed": d.get("platTemp"), + "bed_target": d.get("platTargetTemp"), + "chamber": d.get("chamberTemp"), + "door": d.get("doorStatus"), + "firmware": d.get("firmwareVersion"), + "active_slot": (d.get("matlStationInfo") or {}).get("currentSlot"), + } + + +def format_line(now: str, s: dict) -> str: + progress = ( + f"{float(s['progress']) * 100:5.1f}%" if s["progress"] is not None else " - " + ) + layers = f"{s['layer']}/{s['layers']}" if s["layers"] else "-" + error = ( + f" err={s['error_code']}" if s["error_code"] not in ("", "0", None) else "" + ) + unmapped = "" if s["known"] else " <== UNMAPPED, Home Assistant shows 'unknown'" + return ( + f"{now} {str(s['status']):<12} -> {s['mapped']:<11}" + f" {progress} {layers:>9} bed {s['bed']}/{s['bed_target']}" + f"{error}{unmapped}" + ) + + +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( + "--ha-config", + metavar="DIR", + help="read the credentials from this Home Assistant config directory", + ) + parser.add_argument("--watch", action="store_true", help="poll until interrupted") + parser.add_argument( + "--interval", type=float, default=10.0, help="seconds between polls" + ) + parser.add_argument("--raw", action="store_true", help="dump the whole /detail payload") + parser.add_argument("--log", metavar="FILE", help="append one JSON object per poll") + args = parser.parse_args() + + if args.ha_config: + creds = credentials_from_ha(args.ha_config) + elif args.ip and args.serial and args.check_code: + creds = { + "ip": args.ip, + "serial": args.serial, + "check_code": args.check_code, + "name": "FlashForge", + } + else: + parser.error( + "need --ip, --serial and --check-code (or FF_* env vars), or --ha-config" + ) + + print(f"{creds['name']} @ {creds['ip']}\n") + + log = Path(args.log).open("a", encoding="utf-8") if args.log else None + previous = None + + try: + while True: + now = datetime.now().strftime("%H:%M:%S") + try: + detail = fetch_detail(creds) + except Exception as err: # noqa: BLE001 - a probe reports, it does not raise + print(f"{now} printer unreachable: {err}") + if not args.watch: + return + time.sleep(args.interval) + continue + + summary = summarize(detail) + + if args.raw: + print(json.dumps(detail, indent=2, ensure_ascii=False)) + + # In watch mode only changes are printed: a status that repeats for + # an hour must not bury the moment it changed. The log keeps + # everything regardless. + state = (summary["status"], summary["error_code"]) + if not args.watch or state != previous: + print(format_line(now, summary)) + previous = state + + if log: + log.write( + json.dumps({"time": datetime.now().isoformat(), **summary}) + "\n" + ) + log.flush() + + if not args.watch: + return + time.sleep(args.interval) + except KeyboardInterrupt: + print("\nStopped.") + finally: + if log: + log.close() + + +if __name__ == "__main__": + main() diff --git a/tests/unit/test_binary_sensor_value_functions.py b/tests/unit/test_binary_sensor_value_functions.py index a81a442..7a1bb3f 100644 --- a/tests/unit/test_binary_sensor_value_functions.py +++ b/tests/unit/test_binary_sensor_value_functions.py @@ -30,6 +30,9 @@ def setup_method(self): # Create a mock FFMachineInfo object self.mock_data = Mock() self.mock_data.machine_state = MachineState.READY + # A printer with nothing wrong reports an empty error code. Left as a + # bare Mock this would be truthy, which is not what any printer sends. + self.mock_data.error_code = "" def get_sensor_by_key(self, key: str): """Helper to get binary sensor description by key.""" @@ -108,6 +111,37 @@ def test_has_error_false_when_paused(self): sensor = self.get_sensor_by_key("has_error") assert sensor.value_fn(self.mock_data) is False + def test_has_error_true_when_the_printer_reports_a_code_while_paused(self): + """A clog pauses the print and fills errorCode; the state stays PAUSED. + + Observed live on a Creator 5 Pro (pid 41, firmware 1.9.5): the print + stopped at 89%, the printer displayed "Clog detected", `/detail` + reported `status: "pause"` with `errorCode: "E0163"` - and the problem + sensor stayed off, because it only asked about the machine state. The + one entity meant to say "something needs attention" said nothing for the + entire outage. + """ + self.mock_data.machine_state = MachineState.PAUSED + self.mock_data.error_code = "E0163" + sensor = self.get_sensor_by_key("has_error") + assert sensor.value_fn(self.mock_data) is True + + def test_has_error_true_when_a_code_arrives_in_any_state(self): + """The code is the signal; the state it arrives in is the printer's business.""" + sensor = self.get_sensor_by_key("has_error") + for state in [MachineState.READY, MachineState.PRINTING, MachineState.BUSY]: + self.mock_data.machine_state = state + self.mock_data.error_code = "E0163" + assert sensor.value_fn(self.mock_data) is True, f"Failed for state {state}" + + def test_has_error_ignores_an_empty_code(self): + """An empty string is what a healthy printer reports, not an error.""" + sensor = self.get_sensor_by_key("has_error") + for empty in ["", None]: + self.mock_data.machine_state = MachineState.PRINTING + self.mock_data.error_code = empty + assert sensor.value_fn(self.mock_data) is False, f"Failed for {empty!r}" + # is_paused tests def test_is_paused_true_when_paused(self): """Test is_paused sensor returns True when PAUSED."""