diff --git a/config.py b/config.py index ca30dad..15317e7 100644 --- a/config.py +++ b/config.py @@ -45,3 +45,5 @@ def _env_to_int(name: str, default: int) -> int: DISABLE_MISMATCH_WARNING = _env_to_bool("DISABLE_MISMATCH_WARNING", False) CLEAR_ASSIGNMENT_WHEN_EMPTY = _env_to_bool("CLEAR_ASSIGNMENT_WHEN_EMPTY", False) COLOR_DISTANCE_TOLERANCE = _env_to_int("COLOR_DISTANCE_TOLERANCE", 40) + +DOWNLOADED_FILES = {} # Store locations of currently downloaded temp files diff --git a/filament_usage_tracker.py b/filament_usage_tracker.py index 821a3d9..cd9c31f 100644 --- a/filament_usage_tracker.py +++ b/filament_usage_tracker.py @@ -8,10 +8,10 @@ from pathlib import Path from urllib.parse import urlparse -from config import EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID, TRACK_LAYER_USAGE +from config import EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID, TRACK_LAYER_USAGE, PRINTER_IP, PRINTER_NAME from spoolman_client import consumeSpool from spoolman_service import fetchSpools, getAMSFromTray, trayUid -from tools_3mf import download3mfFromCloud, download3mfFromFTP, download3mfFromLocalFilesystem +from tools_3mf import download3mfFromCloud, download3mfFromFTP, download3mfFromLocalFilesystem, clearTempFile, retrieveModel from print_history import update_filament_spool, update_filament_grams_used, get_all_filament_usage_for_print, update_layer_tracking from logger import log @@ -299,7 +299,10 @@ def _handle_print_start(self, print_obj: dict) -> None: log("[filament-tracker] Print start") model_url = print_obj.get("url") - model_path = self._retrieve_model(model_url) + if not model_url: + log("[filament-tracker] No model URL provided") + return None + model_path = retrieveModel(model_url) if model_path is None: log("Failed to retrieve model. Print will not be tracked.") @@ -419,28 +422,6 @@ def apply_ams_mapping(self, ams_mapping: list[int] | None) -> None: self._maybe_update_predicted_total() self._update_layer_tracking_progress() - def _retrieve_model(self, model_url: str | None) -> str | None: - if not model_url: - log("[filament-tracker] No model URL provided") - return None - - uri = urlparse(model_url) - try: - with tempfile.NamedTemporaryFile(suffix=".3mf", delete=False) as model_file: - if uri.scheme in ("https", "http"): - log(f"[filament-tracker] Downloading model via HTTP(S): {model_url}") - download3mfFromCloud(model_url, model_file) - elif uri.scheme == "local": - log(f"[filament-tracker] Loading model from local path: {uri.path}") - download3mfFromLocalFilesystem(uri.path, model_file) - else: - log(f"[filament-tracker] Downloading model via FTP: {model_url}") - download3mfFromFTP(model_url.rpartition('/')[-1], model_file) # Pull just filename to clear out any unexpected paths - return model_file.name - except Exception as exc: - log(f"Failed to fetch model: {exc}") - return None - def _handle_layer_change(self, layer: int) -> None: if self.active_model is None: return @@ -487,6 +468,9 @@ def _handle_print_end(self) -> None: self._reset_layer_tracking_state() clear_checkpoint() + log("[DEBUG] Clearing temp print file") + clearTempFile(PRINTER_NAME, PRINTER_IP) + def _handle_print_abort(self, status: str = LAYER_TRACKING_STATUS_ABORTED) -> None: if self.active_model is None: return @@ -512,6 +496,9 @@ def _handle_print_abort(self, status: str = LAYER_TRACKING_STATUS_ABORTED) -> No self._reset_layer_tracking_state() clear_checkpoint() + log("[DEBUG] Clearing temp print file") + clearTempFile(PRINTER_NAME, PRINTER_IP) + def _mm_to_grams(self, length_mm: float, diameter_mm: float, density_g_per_cm3: float) -> float: """ Convert filament length in mm to grams. diff --git a/mqtt_bambulab.py b/mqtt_bambulab.py index 2d70488..9fe746d 100644 --- a/mqtt_bambulab.py +++ b/mqtt_bambulab.py @@ -16,10 +16,13 @@ EXTERNAL_SPOOL_ID, TRACK_LAYER_USAGE, CLEAR_ASSIGNMENT_WHEN_EMPTY, + DOWNLOADED_FILES, + PRINTER_NAME, + ) from messages import GET_VERSION, PUSH_ALL, AMS_FILAMENT_SETTING from spoolman_service import spendFilaments, setActiveTray, fetchSpools, clear_active_spool_for_tray -from tools_3mf import getMetaDataFrom3mf +from tools_3mf import getMetaDataFrom3mf, clearTempFile import time import copy from collections.abc import Mapping @@ -158,44 +161,44 @@ def update_dict(original: dict, updates: dict) -> dict: return original -def _parse_grams(value): - try: - return float(value) - except (TypeError, ValueError): - return None - -def _mask_serial(serial: str | None, keep_chars: int = 3) -> str: - if not serial: - return "" - visible = serial[:keep_chars] - if len(serial) <= keep_chars: - return visible - return f"{visible}..." - -def _mask_sn_values(value): - if isinstance(value, dict): - for key, item in value.items(): - if key.lower() == "sn" and isinstance(item, str): - value[key] = _mask_serial(item) - else: - _mask_sn_values(item) - elif isinstance(value, list): - for elem in value: - _mask_sn_values(elem) - -def _mask_mqtt_payload(payload: str) -> str: - try: - data = json.loads(payload) - _mask_sn_values(data) - masked = json.dumps(data, separators=(",", ":")) - except ValueError: - masked = payload - - masked_serial = _mask_serial(PRINTER_ID) - if masked_serial: - masked = masked.replace(PRINTER_ID, masked_serial) - - return masked +def _parse_grams(value): + try: + return float(value) + except (TypeError, ValueError): + return None + +def _mask_serial(serial: str | None, keep_chars: int = 3) -> str: + if not serial: + return "" + visible = serial[:keep_chars] + if len(serial) <= keep_chars: + return visible + return f"{visible}..." + +def _mask_sn_values(value): + if isinstance(value, dict): + for key, item in value.items(): + if key.lower() == "sn" and isinstance(item, str): + value[key] = _mask_serial(item) + else: + _mask_sn_values(item) + elif isinstance(value, list): + for elem in value: + _mask_sn_values(elem) + +def _mask_mqtt_payload(payload: str) -> str: + try: + data = json.loads(payload) + _mask_sn_values(data) + masked = json.dumps(data, separators=(",", ":")) + except ValueError: + masked = payload + + masked_serial = _mask_serial(PRINTER_ID) + if masked_serial: + masked = masked.replace(PRINTER_ID, masked_serial) + + return masked def map_filament(tray_tar): global PENDING_PRINT_METADATA @@ -292,7 +295,14 @@ def processMessage(data): length_used=length_used, estimated_length=estimated_length_mm, ) - + + # # Clear out temp files if they exist on FAILED or FINISHED prints + # if(DOWNLOADED_FILES.get(f"{PRINTER_NAME}_{PRINTER_IP}") and PRINTER_STATE_LAST["print"].get("gecode_state") == "RUNNING" and (PRINTER_STATE["print"].get("gcode_state") == "FAILED" or PRINTER_STATE["print"].get("gcode_state") == "FINISHED")): + # log("would of cleared here") + # #clearTempFile(PRINTER_NAME, PRINTER_IP) + + + #if ("gcode_state" in data["print"] and data["print"]["gcode_state"] == "RUNNING") and ("print_type" in data["print"] and data["print"]["print_type"] != "local") \ # and ("tray_tar" in data["print"] and data["print"]["tray_tar"] != "255") and ("stg_cur" in data["print"] and data["print"]["stg_cur"] == 0 and PRINT_CURRENT_STAGE != 0): @@ -306,7 +316,11 @@ def processMessage(data): ): if not PENDING_PRINT_METADATA: - PENDING_PRINT_METADATA = getMetaDataFrom3mf(PRINTER_STATE["print"]["gcode_file"]) + # TODO Seems to be trying to find an internal gcode file inside the 3mf file? + # And only firing sometimes, do we need this? + #PENDING_PRINT_METADATA = getMetaDataFrom3mf(PRINTER_STATE["print"]["gcode_file"]) + log(f"[DEBUG] Would have tried to grab file {PRINTER_STATE["print"]["gcode_file"]}") + if PENDING_PRINT_METADATA: PENDING_PRINT_METADATA["print_type"] = PRINTER_STATE["print"].get("print_type") PENDING_PRINT_METADATA["task_id"] = PRINTER_STATE["print"].get("task_id") @@ -449,8 +463,8 @@ def on_message(client, userdata, msg): "models_by_id": models_by_id, } - if "print" in data: - append_to_rotating_file("/home/app/logs/mqtt.log", _mask_mqtt_payload(msg.payload.decode())) + if "print" in data: + append_to_rotating_file("/home/app/logs/mqtt.log", _mask_mqtt_payload(msg.payload.decode())) #print(data) diff --git a/tools_3mf.py b/tools_3mf.py index bcbc8c3..724ae24 100644 --- a/tools_3mf.py +++ b/tools_3mf.py @@ -9,7 +9,7 @@ import time import io from datetime import datetime -from config import PRINTER_CODE, PRINTER_IP +from config import PRINTER_CODE, PRINTER_IP, PRINTER_NAME, DOWNLOADED_FILES from urllib.parse import urlparse from logger import log @@ -167,119 +167,111 @@ def getMetaDataFrom3mf(url): try: metadata = {} - # Create a temporary file - with tempfile.NamedTemporaryFile(delete_on_close=False,delete=True, suffix=".3mf") as temp_file: - temp_file_name = temp_file.name + # Retrieve the model + temp_file_name = retrieveModel(url) - if url.startswith("http"): - download3mfFromCloud(url, temp_file) - elif url.startswith("local:"): - download3mfFromLocalFilesystem(url.replace("local:", ""), temp_file) + + metadata["model_path"] = url + + parsed_url = urlparse(url) + metadata["file"] = os.path.basename(parsed_url.path) + + + + # Unzip the 3MF file + with zipfile.ZipFile(temp_file_name, 'r') as z: + # Check for the Metadata/slice_info.config file + slice_info_path = "Metadata/slice_info.config" + if slice_info_path in z.namelist(): + with z.open(slice_info_path) as slice_info_file: + # Parse the XML content of the file + tree = ET.parse(slice_info_file) + root = tree.getroot() + + # Extract id and used_g from each filament + """ + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + + for meta in root.findall(".//plate/metadata"): + if meta.attrib.get("key") == "index": + metadata["plateID"] = meta.attrib.get("value", "") + + usage = {} + filaments= {} + filamentId = 1 + for plate in root.findall(".//plate"): + for filament in plate.findall(".//filament"): + used_g = filament.attrib.get("used_g") + #filamentId = int(filament.attrib.get("id")) + + usage[filamentId] = used_g + filaments[filamentId] = {"id": filamentId, + "tray_info_idx": filament.attrib.get("tray_info_idx"), + "type":filament.attrib.get("type"), + "color": filament.attrib.get("color"), + "used_g": used_g, + "used_m":filament.attrib.get("used_m")} + filamentId += 1 + + metadata["filaments"] = filaments + metadata["usage"] = usage else: - download3mfFromFTP(url.rpartition('/')[-1], temp_file) # Pull just filename to clear out any unexpected paths - - temp_file.close() - metadata["model_path"] = url - - parsed_url = urlparse(url) - metadata["file"] = os.path.basename(parsed_url.path) - - log(f"3MF file downloaded and saved as {temp_file_name}.") - - # Unzip the 3MF file - with zipfile.ZipFile(temp_file_name, 'r') as z: - # Check for the Metadata/slice_info.config file - slice_info_path = "Metadata/slice_info.config" - if slice_info_path in z.namelist(): - with z.open(slice_info_path) as slice_info_file: - # Parse the XML content of the file - tree = ET.parse(slice_info_file) - root = tree.getroot() - - # Extract id and used_g from each filament - """ - - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - """ - - for meta in root.findall(".//plate/metadata"): - if meta.attrib.get("key") == "index": - metadata["plateID"] = meta.attrib.get("value", "") - - usage = {} - filaments= {} - filamentId = 1 - for plate in root.findall(".//plate"): - for filament in plate.findall(".//filament"): - used_g = filament.attrib.get("used_g") - #filamentId = int(filament.attrib.get("id")) - - usage[filamentId] = used_g - filaments[filamentId] = {"id": filamentId, - "tray_info_idx": filament.attrib.get("tray_info_idx"), - "type":filament.attrib.get("type"), - "color": filament.attrib.get("color"), - "used_g": used_g, - "used_m":filament.attrib.get("used_m")} - filamentId += 1 - - metadata["filaments"] = filaments - metadata["usage"] = usage - else: - log(f"File '{slice_info_path}' not found in the archive.") - return {} + log(f"File '{slice_info_path}' not found in the archive.") + return {} - metadata["image"] = time.strftime('%Y%m%d%H%M%S') + ".png" + metadata["image"] = time.strftime('%Y%m%d%H%M%S') + ".png" - with z.open("Metadata/plate_"+metadata["plateID"]+".png") as source_file: - with open(os.path.join(os.getcwd(), 'static', 'prints', metadata["image"]), 'wb') as target_file: - target_file.write(source_file.read()) + with z.open("Metadata/plate_"+metadata["plateID"]+".png") as source_file: + with open(os.path.join(os.getcwd(), 'static', 'prints', metadata["image"]), 'wb') as target_file: + target_file.write(source_file.read()) - # Check for the Metadata/slice_info.config file - gcode_path = "Metadata/plate_"+metadata["plateID"]+".gcode" - metadata["gcode_path"] = gcode_path - if gcode_path in z.namelist(): - with z.open(gcode_path) as gcode_file: - metadata["filamentOrder"] = get_filament_order(gcode_file) + # Check for the Metadata/slice_info.config file + gcode_path = "Metadata/plate_"+metadata["plateID"]+".gcode" + metadata["gcode_path"] = gcode_path + if gcode_path in z.namelist(): + with z.open(gcode_path) as gcode_file: + metadata["filamentOrder"] = get_filament_order(gcode_file) - log(metadata) + log(metadata) - return metadata + return metadata except requests.exceptions.RequestException as e: log(f"Error downloading file: {e}") @@ -293,3 +285,61 @@ def getMetaDataFrom3mf(url): except Exception as e: log(f"An unexpected error occurred: {e}") return {} + +# Reteive the model from temp file cache if we already have it, otherwise grab it from the printer. +def retrieveModel(url): + if not url: + log("[DEBUG] No URL supplied to retireve printer model") + return None + + try: + temp_file_name = "" + + # Check if we have already downloaded the file previously + if not DOWNLOADED_FILES.get(f"{PRINTER_NAME}_{PRINTER_IP}"): + log("[DEBUG] Don't already have the model in temp file cache") + + # Create a temporary file, DELETE is FALSE, make sure to cleanup when printing ends + #TODO handle cleanup on crashes + + # Create OpenSpoolMan temp directory for easier/safer file cleanup + temp_dir = os.path.join(tempfile.gettempdir(), "OpenSpoolMan") + os.makedirs(temp_dir, exist_ok=True) + + with tempfile.NamedTemporaryFile(dir=temp_dir, delete_on_close=False,delete=False,prefix=f"{PRINTER_NAME}_{PRINTER_IP}_", suffix=".3mf") as temp_file: + temp_file_name = temp_file.name + + if url.startswith("http"): + download3mfFromCloud(url, temp_file) + elif url.startswith("local:"): + download3mfFromLocalFilesystem(url.replace("local:", ""), temp_file) + else: + download3mfFromFTP(url.rpartition('/')[-1], temp_file) # Pull just filename to clear out any unexpected paths + + temp_file.close() + DOWNLOADED_FILES[f"{PRINTER_NAME}_{PRINTER_IP}"] = temp_file_name # Use Printer name + IP for future multiple printer support + log(f"3MF file downloaded and saved as {temp_file_name}.") + + # Set to previously downloaded temp file + else: + temp_file_name = DOWNLOADED_FILES[f"{PRINTER_NAME}_{PRINTER_IP}"] + log(f"[DEBUG] Using already cached temp file, path: {temp_file_name}") + + return temp_file_name + + except requests.exceptions.RequestException as e: + log(f"Error downloading file: {e}") + return None + except Exception as e: + log(f"An unexpected error occurred: {e}") + return None + +def clearTempFile(PRINTER_NAME, PRINTER_IP): + + temp_file_path = DOWNLOADED_FILES.pop(f"{PRINTER_NAME}_{PRINTER_IP}", None) + + if temp_file_path: + if os.path.exists(temp_file_path): + os.remove(temp_file_path) + print(f"Temp file: {temp_file_path} cleared for printer: {PRINTER_NAME} {PRINTER_IP}.") +