Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<printer>_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
Expand Down
11 changes: 10 additions & 1 deletion custom_components/flashforge/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
218 changes: 218 additions & 0 deletions scripts/printer_state.py
Original file line number Diff line number Diff line change
@@ -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()
34 changes: 34 additions & 0 deletions tests/unit/test_binary_sensor_value_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
Loading