Improvement: Add AUTO SPEND feature that tracks your filament usage
Bump version to 0.0.2
This commit is contained in:
		
							
								
								
									
										20
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								README.md
									
									
									
									
									
								
							| @@ -22,6 +22,7 @@ Everything works locally without cloud access, you can use scripts/init_bambulab | |||||||
|    - set PRINTER_ACCESS_CODE - On your printer clicking on Setting -> Lan Only Mode -> Access Code (you _don't_ need to enable the LAN Only Mode) |    - set PRINTER_ACCESS_CODE - On your printer clicking on Setting -> Lan Only Mode -> Access Code (you _don't_ need to enable the LAN Only Mode) | ||||||
|    - set PRINTER_IP - On your printer clicking on Setting -> Lan Only Mode -> IP Address (you _don't_ need to enable the LAN Only Mode) |    - set PRINTER_IP - On your printer clicking on Setting -> Lan Only Mode -> IP Address (you _don't_ need to enable the LAN Only Mode) | ||||||
|    - set SPOOLMAN_BASE_URL - according to your SpoolMan installation without trailing slash |    - set SPOOLMAN_BASE_URL - according to your SpoolMan installation without trailing slash | ||||||
|  |    - set AUTO_SPEND - to True if you want for system to track your filament spending (check AUTO_SPEND issues below) default to False | ||||||
|  - Run the server (wsgi.py) |  - Run the server (wsgi.py) | ||||||
|  - Run Spool Man |  - Run Spool Man | ||||||
|  - Add following extra Fields to your SpoolMan: |  - Add following extra Fields to your SpoolMan: | ||||||
| @@ -47,14 +48,33 @@ Run in docker by configuring config.env and running compose.yaml, you will need | |||||||
|  |  | ||||||
| Run in kubernetes using helm chart, where you can configure the ingress with SSL. https://github.com/truecharts/public/blob/master/charts/library/common/values.yaml | Run in kubernetes using helm chart, where you can configure the ingress with SSL. https://github.com/truecharts/public/blob/master/charts/library/common/values.yaml | ||||||
|  |  | ||||||
|  | ### AUTO SPEND | ||||||
|  | You can turn this feature on to automatically update the spool usage in SpoolMan.  | ||||||
|  | This feature is using slicer information about predicted filament weight usage (and in future correlating it with the progress of the printer to compute the estimate of filament used). | ||||||
|  |  | ||||||
|  | This feature has currently following issues/drawbacks: | ||||||
|  |  - Spending on the start of the print | ||||||
|  |  - Not spending according to print process and spending full filament weight even if print fails | ||||||
|  |  - Don't know if it works with LAN mode, since it downloads the 3MF file from cloud | ||||||
|  |  - Doesn't work if you print from SD card | ||||||
|  |  - Not tested with multiple AMS systems | ||||||
|  |  - External spool spending not yet implemented | ||||||
|  |  - Not handling the mismatch between the SpoolMan and AMS (if you don't have the Active Tray information correct in spoolman it won't work properly) | ||||||
|  |  | ||||||
| ### Notes: | ### Notes: | ||||||
|  - If you change the BASE_URL of this app, you will need to reconfigure all NFC TAGS |  - If you change the BASE_URL of this app, you will need to reconfigure all NFC TAGS | ||||||
|  |  | ||||||
| ### TBD: | ### TBD: | ||||||
|  - Filament remaining in AMS (I have only AMS lite, if you have AMS we can test together) |  - Filament remaining in AMS (I have only AMS lite, if you have AMS we can test together) | ||||||
|  - Filament spending based on printing |  - Filament spending based on printing | ||||||
|  |    - TODO: handle situation when the print doesn't finish | ||||||
|  |    - TODO: what about locally run file? | ||||||
|  |    - TODO: test with multiple AMS | ||||||
|  |    - TODO: filament usage in external spool | ||||||
|  - Evidently needed GUI improvements |  - Evidently needed GUI improvements | ||||||
|  - Code cleanup |  - Code cleanup | ||||||
|  - Video showcase |  - Video showcase | ||||||
|  - Docker compose SSL |  - Docker compose SSL | ||||||
|  |  - Logs | ||||||
|  - TODOs |  - TODOs | ||||||
|  |  - Bambu Spools have apparently two RFID chips with two distinct UIDs so if you swap the filament to different tray it may fail, needs rework/testing | ||||||
|   | |||||||
| @@ -1 +1 @@ | |||||||
| __version__ = '0.0.1' | __version__ = '0.0.2' | ||||||
|   | |||||||
| @@ -3,3 +3,4 @@ PRINTER_ACCESS_CODE= | |||||||
| PRINTER_ID= | PRINTER_ID= | ||||||
| PRINTER_IP= | PRINTER_IP= | ||||||
| SPOOLMAN_BASE_URL= | SPOOLMAN_BASE_URL= | ||||||
|  | AUTO_SPEND=False | ||||||
|   | |||||||
| @@ -6,3 +6,4 @@ PRINTER_CODE = os.getenv('PRINTER_ACCESS_CODE')       # Printer access code - Ru | |||||||
| PRINTER_IP = os.getenv('PRINTER_IP')     # Printer local IP address - Check wireless on printer | PRINTER_IP = os.getenv('PRINTER_IP')     # Printer local IP address - Check wireless on printer | ||||||
| SPOOLMAN_BASE_URL = os.getenv('SPOOLMAN_BASE_URL') | SPOOLMAN_BASE_URL = os.getenv('SPOOLMAN_BASE_URL') | ||||||
| SPOOLMAN_API_URL = f"{SPOOLMAN_BASE_URL}/api/v1" | SPOOLMAN_API_URL = f"{SPOOLMAN_BASE_URL}/api/v1" | ||||||
|  | AUTO_SPEND = os.getenv('AUTO_SPEND', False) | ||||||
|   | |||||||
| @@ -5,14 +5,14 @@ from threading import Thread | |||||||
|  |  | ||||||
| import paho.mqtt.client as mqtt | import paho.mqtt.client as mqtt | ||||||
|  |  | ||||||
| from config import PRINTER_ID, PRINTER_CODE, PRINTER_IP | from config import PRINTER_ID, PRINTER_CODE, PRINTER_IP, AUTO_SPEND | ||||||
| from messages import GET_VERSION, PUSH_ALL | from messages import GET_VERSION, PUSH_ALL | ||||||
| from spoolman_client import fetchSpoolList, patchExtraTags | from spoolman_client import fetchSpoolList, patchExtraTags, consumeSpool | ||||||
|  | from tools_3mf import getFilamentsUsageFrom3mf | ||||||
|  |  | ||||||
| MQTT_CLIENT = {}  # Global variable storing MQTT Client | MQTT_CLIENT = {}  # Global variable storing MQTT Client | ||||||
| LAST_AMS_CONFIG = {}  # Global variable storing last AMS configuration | LAST_AMS_CONFIG = {}  # Global variable storing last AMS configuration | ||||||
|  |  | ||||||
|  |  | ||||||
| def num2letter(num): | def num2letter(num): | ||||||
|   return chr(ord("A") + int(num)) |   return chr(ord("A") + int(num)) | ||||||
|  |  | ||||||
| @@ -28,22 +28,45 @@ def publish(client, msg): | |||||||
|   return False |   return False | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def spendFilaments(filaments_usage): | ||||||
|  |   print(filaments_usage) | ||||||
|  |   ams_usage = {} | ||||||
|  |   for tray_id, usage in filaments_usage: | ||||||
|  |     if tray_id != -1: | ||||||
|  |       #TODO: hardcoded ams_id | ||||||
|  |       if ams_usage.get(f"{PRINTER_ID}_0_{tray_id}"): | ||||||
|  |         ams_usage[f"{PRINTER_ID}_0_{tray_id}"] += float(usage) | ||||||
|  |       else: | ||||||
|  |         ams_usage[f"{PRINTER_ID}_0_{tray_id}"] = float(usage) | ||||||
|  |  | ||||||
|  |   for spool in fetchSpools(): | ||||||
|  |     #TODO: What if there is a mismatch between AMS and SpoolMan? | ||||||
|  |     if spool.get("extra") and spool.get("extra").get("active_tray") and ams_usage.get(json.loads(spool.get("extra").get("active_tray"))): | ||||||
|  |       consumeSpool(spool["id"], ams_usage.get(json.loads(spool.get("extra").get("active_tray")))) | ||||||
|  |  | ||||||
| # Inspired by https://github.com/Donkie/Spoolman/issues/217#issuecomment-2303022970 | # Inspired by https://github.com/Donkie/Spoolman/issues/217#issuecomment-2303022970 | ||||||
| def on_message(client, userdata, msg): | def on_message(client, userdata, msg): | ||||||
|   global LAST_AMS_CONFIG |   global LAST_AMS_CONFIG | ||||||
|   # TODO: Consume spool |  | ||||||
|   try: |   try: | ||||||
|     data = json.loads(msg.payload.decode()) |     data = json.loads(msg.payload.decode()) | ||||||
|     # print(data) |     #print(data) | ||||||
|  |     if AUTO_SPEND: | ||||||
|  |       #Prepare AMS spending estimation | ||||||
|  |       if "print" in data and "command" in data["print"] and data["print"]["command"] == "project_file" and "url" in data["print"]: | ||||||
|  |         expected_filaments_usage = getFilamentsUsageFrom3mf(data["print"]["url"]) | ||||||
|  |         ams_used = data["print"]["use_ams"] | ||||||
|  |         ams_mapping = data["print"]["ams_mapping"] | ||||||
|  |         if ams_used: | ||||||
|  |           spendFilaments(zip(ams_mapping, expected_filaments_usage)) | ||||||
|  |  | ||||||
|  |     #Save external spool tray data | ||||||
|     if "print" in data and "vt_tray" in data["print"]: |     if "print" in data and "vt_tray" in data["print"]: | ||||||
|       print(data) |  | ||||||
|       LAST_AMS_CONFIG["vt_tray"] = data["print"]["vt_tray"] |       LAST_AMS_CONFIG["vt_tray"] = data["print"]["vt_tray"] | ||||||
|  |  | ||||||
|  |     #Save ams spool data | ||||||
|     if "print" in data and "ams" in data["print"] and "ams" in data["print"]["ams"]: |     if "print" in data and "ams" in data["print"] and "ams" in data["print"]["ams"]: | ||||||
|       print(data) |  | ||||||
|       LAST_AMS_CONFIG["ams"] = data["print"]["ams"]["ams"] |       LAST_AMS_CONFIG["ams"] = data["print"]["ams"]["ams"] | ||||||
|  |  | ||||||
|       print(LAST_AMS_CONFIG) |  | ||||||
|       for ams in data["print"]["ams"]["ams"]: |       for ams in data["print"]["ams"]["ams"]: | ||||||
|         print(f"AMS [{num2letter(ams['id'])}] (hum: {ams['humidity']}, temp: {ams['temp']}ºC)") |         print(f"AMS [{num2letter(ams['id'])}] (hum: {ams['humidity']}, temp: {ams['temp']}ºC)") | ||||||
|         for tray in ams["tray"]: |         for tray in ams["tray"]: | ||||||
|   | |||||||
| @@ -25,3 +25,10 @@ def fetchSpoolList(): | |||||||
|   print(response.status_code) |   print(response.status_code) | ||||||
|   print(response.text) |   print(response.text) | ||||||
|   return response.json() |   return response.json() | ||||||
|  |  | ||||||
|  | def consumeSpool(spool_id, use_weight): | ||||||
|  |   response = requests.put(f"{SPOOLMAN_API_URL}/spool/{spool_id}/use", json={ | ||||||
|  |     "use_weight": use_weight | ||||||
|  |   }) | ||||||
|  |   print(response.status_code) | ||||||
|  |   print(response.text) | ||||||
|   | |||||||
							
								
								
									
										70
									
								
								tools_3mf.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								tools_3mf.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | |||||||
|  | import requests | ||||||
|  | import zipfile | ||||||
|  | import tempfile | ||||||
|  | import xml.etree.ElementTree as ET | ||||||
|  |  | ||||||
|  | def getFilamentsUsageFrom3mf(url): | ||||||
|  |   """ | ||||||
|  |   Download a 3MF file from a URL, unzip it, and parse filament usage. | ||||||
|  |  | ||||||
|  |   Args: | ||||||
|  |       url (str): URL to the 3MF file. | ||||||
|  |  | ||||||
|  |   Returns: | ||||||
|  |       list[dict]: List of dictionaries with `tray_info_idx` and `used_g`. | ||||||
|  |   """ | ||||||
|  |   try: | ||||||
|  |     # Create a temporary file | ||||||
|  |     with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as temp_file: | ||||||
|  |       temp_file_name = temp_file.name | ||||||
|  |       print("Downloading 3MF file...") | ||||||
|  |  | ||||||
|  |       # Download the file and save it to the temporary file | ||||||
|  |       response = requests.get(url) | ||||||
|  |       response.raise_for_status() | ||||||
|  |       temp_file.write(response.content) | ||||||
|  |       print(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 | ||||||
|  |           result = [] | ||||||
|  |           for plate in root.findall(".//plate"): | ||||||
|  |             for filament in plate.findall(".//filament"): | ||||||
|  |               used_g = filament.attrib.get("used_g") | ||||||
|  |               if used_g: | ||||||
|  |                 result.append(used_g) | ||||||
|  |               else: | ||||||
|  |                 result.append('0.0') | ||||||
|  |           return result | ||||||
|  |       else: | ||||||
|  |         print(f"File '{slice_info_path}' not found in the archive.") | ||||||
|  |         return [] | ||||||
|  |   except requests.exceptions.RequestException as e: | ||||||
|  |     print(f"Error downloading file: {e}") | ||||||
|  |     return [] | ||||||
|  |   except zipfile.BadZipFile: | ||||||
|  |     print("The downloaded file is not a valid 3MF archive.") | ||||||
|  |     return [] | ||||||
|  |   except ET.ParseError: | ||||||
|  |     print("Error parsing the XML file.") | ||||||
|  |     return [] | ||||||
|  |   except Exception as e: | ||||||
|  |     print(f"An unexpected error occurred: {e}") | ||||||
|  |     return [] | ||||||
|  |   finally: | ||||||
|  |     # Cleanup: Delete the temporary file | ||||||
|  |     try: | ||||||
|  |       import os | ||||||
|  |       if os.path.exists(temp_file_name): | ||||||
|  |         os.remove(temp_file_name) | ||||||
|  |     except Exception as cleanup_error: | ||||||
|  |       print(f"Error during cleanup: {cleanup_error}") | ||||||
		Reference in New Issue
	
	Block a user