From 41de15eb94dc02da7c3f2aa4d078872673a89fce Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Wed, 17 Dec 2025 22:16:04 +0100 Subject: [PATCH 1/6] implemented Bambulab Spool linking and AMS model detection --- app.py | 1040 ++++++++++++++------------ docker-compose.yaml | 2 +- mqtt_bambulab.py | 91 ++- spoolman_service.py | 21 + templates/assign_bambu_spool.html | 7 + templates/fragments/list_spools.html | 9 + templates/fragments/tray.html | 19 + tests/test_ams_model_detection.py | 43 ++ tests/test_tray_remaining.py | 39 + 9 files changed, 789 insertions(+), 482 deletions(-) create mode 100644 templates/assign_bambu_spool.html create mode 100644 tests/test_ams_model_detection.py create mode 100644 tests/test_tray_remaining.py diff --git a/app.py b/app.py index a7b846fb..bae44ba1 100644 --- a/app.py +++ b/app.py @@ -1,490 +1,570 @@ -import json -import math -import os -import traceback -import uuid - -from flask import Flask, request, render_template, redirect, url_for - -from config import BASE_URL, AUTO_SPEND, SPOOLMAN_BASE_URL, EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID, PRINTER_NAME -from filament import generate_filament_brand_code, generate_filament_temperatures -from frontend_utils import color_is_dark -from messages import AMS_FILAMENT_SETTING -import mqtt_bambulab -import print_history as print_history_service -import spoolman_client -import spoolman_service -import test_data -from spoolman_service import augmentTrayDataWithSpoolMan, trayUid - -_TEST_PATCH_CONTEXT = None -if test_data.TEST_MODE_FLAG: - _TEST_PATCH_CONTEXT = test_data.activate_test_data_patches() - -USE_TEST_DATA = test_data.test_data_active() -READ_ONLY_MODE = (not USE_TEST_DATA) and os.getenv("OPENSPOOLMAN_LIVE_READONLY") == "1" - -if not USE_TEST_DATA: - mqtt_bambulab.init_mqtt() - -app = Flask(__name__) - -@app.context_processor -def fronted_utilities(): - return dict(SPOOLMAN_BASE_URL=SPOOLMAN_BASE_URL, AUTO_SPEND=AUTO_SPEND, color_is_dark=color_is_dark, BASE_URL=BASE_URL, EXTERNAL_SPOOL_AMS_ID=EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID=EXTERNAL_SPOOL_ID, PRINTER_MODEL=mqtt_bambulab.getPrinterModel(), PRINTER_NAME=PRINTER_NAME) - -@app.route("/issue") -def issue(): - if not mqtt_bambulab.isMqttClientConnected(): - return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") - - ams_id = request.args.get("ams") - tray_id = request.args.get("tray") - if not all([ams_id, tray_id]): - return render_template('error.html', exception="Missing AMS ID, or Tray ID.") - - fix_ams = None - tray_data = None - - spool_list = mqtt_bambulab.fetchSpools() - last_ams_config = mqtt_bambulab.getLastAMSConfig() - if ams_id == EXTERNAL_SPOOL_AMS_ID: - fix_ams = last_ams_config.get("vt_tray", {}) - tray_data = fix_ams - else: - for ams in last_ams_config.get("ams", []): - if str(ams["id"]) == str(ams_id): - fix_ams = ams - break - - if fix_ams: - for tray in fix_ams.get("tray", []): - if str(tray["id"]) == str(tray_id): - tray_data = tray - break - - active_spool = None - for spool in spool_list: - if spool.get("extra") and spool["extra"].get("active_tray") and spool["extra"]["active_tray"] == json.dumps(trayUid(ams_id, tray_id)): - active_spool = spool - break - - if tray_data: - augmentTrayDataWithSpoolMan(spool_list, tray_data, trayUid(ams_id, tray_id)) - - #TODO: Determine issue - #New bambulab spool - #Tray empty, but spoolman has record - #Extra tag mismatch? - #COLor/type mismatch - - return render_template('issue.html', fix_ams=fix_ams, tray_data=tray_data, ams_id=ams_id, tray_id=tray_id, active_spool=active_spool) - -@app.route("/fill") -def fill(): - if not mqtt_bambulab.isMqttClientConnected(): - return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") - - ams_id = request.args.get("ams") - tray_id = request.args.get("tray") - if not all([ams_id, tray_id]): - return render_template('error.html', exception="Missing AMS ID, or Tray ID.") - - spool_id = request.args.get("spool_id") - if spool_id: - if READ_ONLY_MODE: - return render_template('error.html', exception="Live read-only mode: assigning spools to trays is disabled.") - - spool_data = spoolman_client.getSpoolById(spool_id) - mqtt_bambulab.setActiveTray(spool_id, spool_data["extra"], ams_id, tray_id) - setActiveSpool(ams_id, tray_id, spool_data) - return redirect(url_for('home', success_message=f"Updated Spool ID {spool_id} to AMS {ams_id}, Tray {tray_id}.")) - else: - spools = mqtt_bambulab.fetchSpools() - - materials = extract_materials(spools) - selected_materials = [] - - try: - last_ams_config = mqtt_bambulab.getLastAMSConfig() - default_material = None - - if ams_id == EXTERNAL_SPOOL_AMS_ID: - default_material = last_ams_config.get("vt_tray", {}).get("tray_type") - else: - for ams in last_ams_config.get("ams", []): - if str(ams.get("id")) != str(ams_id): - continue - - for tray in ams.get("tray", []): - if str(tray.get("id")) == str(tray_id): - default_material = tray.get("tray_type") - break - - if default_material and default_material in materials: - selected_materials.append(default_material) - except Exception: - pass - - return render_template('fill.html', spools=spools, ams_id=ams_id, tray_id=tray_id, materials=materials, selected_materials=selected_materials) - +import json +import math +import os +import traceback +import uuid + +from flask import Flask, request, render_template, redirect, url_for + +from config import BASE_URL, AUTO_SPEND, SPOOLMAN_BASE_URL, EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID, PRINTER_NAME +from filament import generate_filament_brand_code, generate_filament_temperatures +from frontend_utils import color_is_dark +from messages import AMS_FILAMENT_SETTING +import mqtt_bambulab +import print_history as print_history_service +import spoolman_client +import spoolman_service +import test_data +from spoolman_service import augmentTrayDataWithSpoolMan, trayUid + +_TEST_PATCH_CONTEXT = None +if test_data.TEST_MODE_FLAG: + _TEST_PATCH_CONTEXT = test_data.activate_test_data_patches() + +USE_TEST_DATA = test_data.test_data_active() +READ_ONLY_MODE = (not USE_TEST_DATA) and os.getenv("OPENSPOOLMAN_LIVE_READONLY") == "1" + +if not USE_TEST_DATA: + mqtt_bambulab.init_mqtt() + +app = Flask(__name__) + +@app.context_processor +def fronted_utilities(): + printer_model = mqtt_bambulab.getPrinterModel() or {} + ams_models_by_id = mqtt_bambulab.getDetectedAmsModelsById() + + return dict( + SPOOLMAN_BASE_URL=SPOOLMAN_BASE_URL, + AUTO_SPEND=AUTO_SPEND, + AMS_MODELS_BY_ID=ams_models_by_id, + color_is_dark=color_is_dark, + BASE_URL=BASE_URL, + EXTERNAL_SPOOL_AMS_ID=EXTERNAL_SPOOL_AMS_ID, + EXTERNAL_SPOOL_ID=EXTERNAL_SPOOL_ID, + PRINTER_MODEL=printer_model, + PRINTER_NAME=PRINTER_NAME, + ) + +@app.route("/issue") +def issue(): + if not mqtt_bambulab.isMqttClientConnected(): + return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") + + ams_id = request.args.get("ams") + tray_id = request.args.get("tray") + if not all([ams_id, tray_id]): + return render_template('error.html', exception="Missing AMS ID, or Tray ID.") + + fix_ams = None + tray_data = None + + spool_list = mqtt_bambulab.fetchSpools() + last_ams_config = mqtt_bambulab.getLastAMSConfig() + if ams_id == EXTERNAL_SPOOL_AMS_ID: + fix_ams = last_ams_config.get("vt_tray", {}) + tray_data = fix_ams + else: + for ams in last_ams_config.get("ams", []): + if str(ams["id"]) == str(ams_id): + fix_ams = ams + break + + if fix_ams: + for tray in fix_ams.get("tray", []): + if str(tray["id"]) == str(tray_id): + tray_data = tray + break + + active_spool = None + for spool in spool_list: + if spool.get("extra") and spool["extra"].get("active_tray") and spool["extra"]["active_tray"] == json.dumps(trayUid(ams_id, tray_id)): + active_spool = spool + break + + if tray_data: + augmentTrayDataWithSpoolMan(spool_list, tray_data, trayUid(ams_id, tray_id)) + + #TODO: Determine issue + #New bambulab spool + #Tray empty, but spoolman has record + #Extra tag mismatch? + #COLor/type mismatch + + return render_template('issue.html', fix_ams=fix_ams, tray_data=tray_data, ams_id=ams_id, tray_id=tray_id, active_spool=active_spool) + +@app.route("/fill") +def fill(): + if not mqtt_bambulab.isMqttClientConnected(): + return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") + + ams_id = request.args.get("ams") + tray_id = request.args.get("tray") + if not all([ams_id, tray_id]): + return render_template('error.html', exception="Missing AMS ID, or Tray ID.") + + spool_id = request.args.get("spool_id") + if spool_id: + if READ_ONLY_MODE: + return render_template('error.html', exception="Live read-only mode: assigning spools to trays is disabled.") + + spool_data = spoolman_client.getSpoolById(spool_id) + mqtt_bambulab.setActiveTray(spool_id, spool_data["extra"], ams_id, tray_id) + setActiveSpool(ams_id, tray_id, spool_data) + return redirect(url_for('home', success_message=f"Updated Spool ID {spool_id} to AMS {ams_id}, Tray {tray_id}.")) + else: + spools = mqtt_bambulab.fetchSpools() + + materials = extract_materials(spools) + selected_materials = [] + + try: + last_ams_config = mqtt_bambulab.getLastAMSConfig() + default_material = None + + if ams_id == EXTERNAL_SPOOL_AMS_ID: + default_material = last_ams_config.get("vt_tray", {}).get("tray_type") + else: + for ams in last_ams_config.get("ams", []): + if str(ams.get("id")) != str(ams_id): + continue + + for tray in ams.get("tray", []): + if str(tray.get("id")) == str(tray_id): + default_material = tray.get("tray_type") + break + + if default_material and default_material in materials: + selected_materials.append(default_material) + except Exception: + pass + + return render_template('fill.html', spools=spools, ams_id=ams_id, tray_id=tray_id, materials=materials, selected_materials=selected_materials) + +@app.route("/assign_bambu_spool") +def assign_bambu_spool(): + if not mqtt_bambulab.isMqttClientConnected(): + return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") + + bambu_tag = request.args.get("tag") + ams_id = request.args.get("ams") + tray_id = request.args.get("tray") + spool_id = request.args.get("spool_id") + + if not all([bambu_tag, ams_id, tray_id]): + return render_template('error.html', exception="Missing AMS ID, Tray ID, or Bambu spool tag.") + + if bambu_tag == "00000000000000000000000000000000": + return render_template('error.html', exception="No Bambu spool was detected in this tray.") + + if spool_id: + if READ_ONLY_MODE: + return render_template('error.html', exception="Live read-only mode: linking Bambu spools is disabled.") + + spool_data = spoolman_client.getSpoolById(spool_id) + extras = spool_data.get("extra") or {} + + spoolman_client.patchExtraTags(spool_id, extras, { + "tag": json.dumps(bambu_tag), + }) + + mqtt_bambulab.setActiveTray(spool_id, extras, ams_id, tray_id) + setActiveSpool(ams_id, tray_id, spool_data) + + return redirect(url_for('home', success_message=f"Linked Bambu spool to SpoolMan spool {spool_id} on AMS {ams_id}, Tray {tray_id}.")) + + spools = mqtt_bambulab.fetchSpools() + materials = extract_materials(spools) + selected_materials = [] + + try: + last_ams_config = mqtt_bambulab.getLastAMSConfig() + default_material = None + + if ams_id == EXTERNAL_SPOOL_AMS_ID: + default_material = last_ams_config.get("vt_tray", {}).get("tray_type") + else: + for ams in last_ams_config.get("ams", []): + if str(ams.get("id")) != str(ams_id): + continue + + for tray in ams.get("tray", []): + if str(tray.get("id")) == str(tray_id): + default_material = tray.get("tray_type") + break + + if default_material and default_material in materials: + selected_materials.append(default_material) + except Exception: + pass + + return render_template( + 'assign_bambu_spool.html', + spools=spools, + ams_id=ams_id, + tray_id=tray_id, + bambu_tag=bambu_tag, + materials=materials, + selected_materials=selected_materials, + ) + @app.route("/spool_info") def spool_info(): if not mqtt_bambulab.isMqttClientConnected(): - return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") - - try: - tag_id = request.args.get("tag_id") - spool_id = request.args.get("spool_id") + return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") + + try: + tag_id = request.args.get("tag_id") + spool_id = request.args.get("spool_id") last_ams_config = mqtt_bambulab.getLastAMSConfig() - ams_data = last_ams_config.get("ams", []) - vt_tray_data = last_ams_config.get("vt_tray", {}) + ams_data = last_ams_config.get("ams", []) + vt_tray_data = last_ams_config.get("vt_tray", {}) spool_list = mqtt_bambulab.fetchSpools() - - issue = False - #TODO: Fix issue when external spool info is reset via bambulab interface - augmentTrayDataWithSpoolMan(spool_list, vt_tray_data, trayUid(EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID)) - issue |= vt_tray_data.get("issue", False) - - for ams in ams_data: - for tray in ams["tray"]: - augmentTrayDataWithSpoolMan(spool_list, tray, trayUid(ams["id"], tray["id"])) - issue |= tray.get("issue", False) - - if not tag_id and not spool_id: - return render_template('error.html', exception="TAG ID or spool_id is required as a query parameter (e.g., ?tag_id=RFID123 or ?spool_id=1)") - + + issue = False + #TODO: Fix issue when external spool info is reset via bambulab interface + augmentTrayDataWithSpoolMan(spool_list, vt_tray_data, trayUid(EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID)) + issue |= vt_tray_data.get("issue", False) + + for ams in ams_data: + for tray in ams["tray"]: + augmentTrayDataWithSpoolMan(spool_list, tray, trayUid(ams["id"], tray["id"])) + issue |= tray.get("issue", False) + + if not tag_id and not spool_id: + return render_template('error.html', exception="TAG ID or spool_id is required as a query parameter (e.g., ?tag_id=RFID123 or ?spool_id=1)") + spools = mqtt_bambulab.fetchSpools() - current_spool = None - - spool_id_int = None - if spool_id is not None: - try: - spool_id_int = int(spool_id) - except ValueError: - return render_template('error.html', exception="Invalid spool_id provided") - - for spool in spools: - if spool_id_int is not None and spool['id'] == spool_id_int: - current_spool = spool - if not tag_id: - tag_value = spool.get("extra", {}).get("tag") - if tag_value: - tag_id = json.loads(tag_value) - break - - if not tag_id: - continue - - if not spool.get("extra", {}).get("tag"): - continue - - tag = json.loads(spool["extra"]["tag"]) - if tag != tag_id: - continue - - current_spool = spool - break - - if current_spool: - return render_template('spool_info.html', tag_id=tag_id, current_spool=current_spool, ams_data=ams_data, vt_tray_data=vt_tray_data, issue=issue) - else: - return render_template('error.html', exception="Spool not found") - except Exception as e: - traceback.print_exc() - return render_template('error.html', exception=str(e)) - - -@app.route("/spool/info/") -@app.route("/spool/show/") -def spoolman_compatible_spool_info(spool_id): - query_params = {"spool_id": spool_id} - tag_id = request.args.get("tag_id") - - if tag_id: - query_params["tag_id"] = tag_id - - return redirect(url_for('spool_info', **query_params)) - - + current_spool = None + + spool_id_int = None + if spool_id is not None: + try: + spool_id_int = int(spool_id) + except ValueError: + return render_template('error.html', exception="Invalid spool_id provided") + + for spool in spools: + if spool_id_int is not None and spool['id'] == spool_id_int: + current_spool = spool + if not tag_id: + tag_value = spool.get("extra", {}).get("tag") + if tag_value: + tag_id = json.loads(tag_value) + break + + if not tag_id: + continue + + if not spool.get("extra", {}).get("tag"): + continue + + tag = json.loads(spool["extra"]["tag"]) + if tag != tag_id: + continue + + current_spool = spool + break + + if current_spool: + return render_template('spool_info.html', tag_id=tag_id, current_spool=current_spool, ams_data=ams_data, vt_tray_data=vt_tray_data, issue=issue) + else: + return render_template('error.html', exception="Spool not found") + except Exception as e: + traceback.print_exc() + return render_template('error.html', exception=str(e)) + + +@app.route("/spool/info/") +@app.route("/spool/show/") +def spoolman_compatible_spool_info(spool_id): + query_params = {"spool_id": spool_id} + tag_id = request.args.get("tag_id") + + if tag_id: + query_params["tag_id"] = tag_id + + return redirect(url_for('spool_info', **query_params)) + + @app.route("/tray_load") def tray_load(): if not mqtt_bambulab.isMqttClientConnected(): - return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") - - tag_id = request.args.get("tag_id") - ams_id = request.args.get("ams") - tray_id = request.args.get("tray") - spool_id = request.args.get("spool_id") - - if not all([ams_id, tray_id, spool_id]): - return render_template('error.html', exception="Missing AMS ID, or Tray ID or spool_id.") - - if READ_ONLY_MODE: - return render_template('error.html', exception="Live read-only mode: assigning spools to trays is disabled.") - - try: - # Update Spoolman with the selected tray - spool_data = spoolman_client.getSpoolById(spool_id) - mqtt_bambulab.setActiveTray(spool_id, spool_data["extra"], ams_id, tray_id) - setActiveSpool(ams_id, tray_id, spool_data) - - return redirect(url_for('home', success_message=f"Updated Spool ID {spool_id} with TAG id {tag_id} to AMS {ams_id}, Tray {tray_id}.")) - except Exception as e: - traceback.print_exc() - return render_template('error.html', exception=str(e)) - -def setActiveSpool(ams_id, tray_id, spool_data): - if USE_TEST_DATA or READ_ONLY_MODE: - return None - - if not mqtt_bambulab.isMqttClientConnected(): - return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") - - ams_message = AMS_FILAMENT_SETTING - ams_message["print"]["sequence_id"] = 0 - ams_message["print"]["ams_id"] = int(ams_id) - ams_message["print"]["tray_id"] = int(tray_id) - - if "color_hex" in spool_data["filament"]: - ams_message["print"]["tray_color"] = spool_data["filament"]["color_hex"].upper() + "FF" - else: - ams_message["print"]["tray_color"] = spool_data["filament"]["multi_color_hexes"].split(',')[0].upper() + "FF" - - if "nozzle_temperature" in spool_data["filament"]["extra"]: - nozzle_temperature_range = spool_data["filament"]["extra"]["nozzle_temperature"].strip("[]").split(",") - ams_message["print"]["nozzle_temp_min"] = int(nozzle_temperature_range[0]) - ams_message["print"]["nozzle_temp_max"] = int(nozzle_temperature_range[1]) - else: - nozzle_temperature_range_obj = generate_filament_temperatures(spool_data["filament"]["material"], - spool_data["filament"]["vendor"]["name"]) - ams_message["print"]["nozzle_temp_min"] = int(nozzle_temperature_range_obj["filament_min_temp"]) - ams_message["print"]["nozzle_temp_max"] = int(nozzle_temperature_range_obj["filament_max_temp"]) - - ams_message["print"]["tray_type"] = spool_data["filament"]["material"] - - filament_brand_code = {} - filament_brand_code["brand_code"] = spool_data["filament"]["extra"].get("filament_id", "").strip('"') - filament_brand_code["sub_brand_code"] = "" - - if filament_brand_code["brand_code"] == "": - filament_brand_code = generate_filament_brand_code(spool_data["filament"]["material"], - spool_data["filament"]["vendor"]["name"], - spool_data["filament"]["extra"].get("type", "")) - - ams_message["print"]["tray_info_idx"] = filament_brand_code["brand_code"] - - # TODO: test sub_brand_code - # ams_message["print"]["tray_sub_brands"] = filament_brand_code["sub_brand_code"] - ams_message["print"]["tray_sub_brands"] = "" - - print(ams_message) - mqtt_bambulab.publish(mqtt_bambulab.getMqttClient(), ams_message) - -@app.route("/") -def home(): - if not mqtt_bambulab.isMqttClientConnected(): - return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") - - try: - last_ams_config = mqtt_bambulab.getLastAMSConfig() - ams_data = last_ams_config.get("ams", []) - vt_tray_data = last_ams_config.get("vt_tray", {}) - spool_list = mqtt_bambulab.fetchSpools() - success_message = request.args.get("success_message") - - issue = False - #TODO: Fix issue when external spool info is reset via bambulab interface - augmentTrayDataWithSpoolMan(spool_list, vt_tray_data, trayUid(EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID)) - issue |= vt_tray_data["issue"] - - for ams in ams_data: - for tray in ams["tray"]: - augmentTrayDataWithSpoolMan(spool_list, tray, trayUid(ams["id"], tray["id"])) - issue |= tray["issue"] - - return render_template('index.html', success_message=success_message, ams_data=ams_data, vt_tray_data=vt_tray_data, issue=issue) - except Exception as e: - traceback.print_exc() - return render_template('error.html', exception=str(e)) - -def sort_spools(spools): - def condition(item): - # Ensure the item has an "extra" key and is a dictionary - if not isinstance(item, dict) or "extra" not in item or not isinstance(item["extra"], dict): - return False - - # Check the specified condition - return item["extra"].get("tag") or item["extra"].get("tag") == "" - - # Sort with the custom condition: False values come first - return sorted(spools, key=lambda spool: bool(condition(spool))) - - -def extract_materials(spools): - materials = set() - - for spool in spools: - filament = None - - if isinstance(spool, dict): - filament = spool.get("filament") - else: - filament = getattr(spool, "filament", None) - - if isinstance(filament, dict): - material = filament.get("material") - else: - material = getattr(filament, "material", None) - - if material: - materials.add(material) - - return sorted(materials) - -@app.route("/assign_tag") -def assign_tag(): - if not mqtt_bambulab.isMqttClientConnected(): - return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") - - try: - spools = sort_spools(mqtt_bambulab.fetchSpools()) - - materials = extract_materials(spools) - selected_materials = [] - requested_material = request.args.get("material") - - if requested_material and requested_material in materials: - selected_materials.append(requested_material) - - return render_template('assign_tag.html', spools=spools, materials=materials, selected_materials=selected_materials) - except Exception as e: - traceback.print_exc() - return render_template('error.html', exception=str(e)) - -@app.route("/write_tag") -def write_tag(): - try: - spool_id = request.args.get("spool_id") - - if not spool_id: - return render_template('error.html', exception="spool ID is required as a query parameter (e.g., ?spool_id=1)") - - myuuid = str(uuid.uuid4()) - - spoolman_client.patchExtraTags(spool_id, {}, { - "tag": json.dumps(myuuid), - }) - return render_template('write_tag.html', myuuid=myuuid) - except Exception as e: - traceback.print_exc() - return render_template('error.html', exception=str(e)) - -@app.route('/health', methods=['GET']) -def health(): - return "OK", 200 - -@app.route("/print_history") -def print_history(): - spoolman_settings = spoolman_service.getSettings() - - try: - page = max(int(request.args.get("page", 1)), 1) - except ValueError: - page = 1 - per_page = 50 - offset = max((page - 1) * per_page, 0) - - ams_slot = request.args.get("ams_slot") - print_id = request.args.get("print_id") - spool_id = request.args.get("spool_id") - old_spool_id = request.args.get("old_spool_id") - - if not old_spool_id: - old_spool_id = -1 - - if READ_ONLY_MODE and all([ams_slot, print_id, spool_id]): - return render_template('error.html', exception="Live read-only mode: updating print-to-spool assignments is disabled.") - - if all([ams_slot, print_id, spool_id]): - filament = print_history_service.get_filament_for_slot(print_id, ams_slot) - print_history_service.update_filament_spool(print_id, ams_slot, spool_id) - - if(filament["spool_id"] != int(spool_id) and (not old_spool_id or (old_spool_id and filament["spool_id"] == int(old_spool_id)))): - if old_spool_id and int(old_spool_id) != -1: - spoolman_client.consumeSpool(old_spool_id, filament["grams_used"] * -1) - - spoolman_client.consumeSpool(spool_id, filament["grams_used"]) - - prints, total_prints = print_history_service.get_prints_with_filament(limit=per_page, offset=offset) - - spool_list = mqtt_bambulab.fetchSpools() - - for print in prints: - print["filament_usage"] = json.loads(print["filament_info"]) - print["total_cost"] = 0 - - for filament in print["filament_usage"]: - if filament["spool_id"]: - for spool in spool_list: - if spool['id'] == filament["spool_id"]: - filament["spool"] = spool - filament["cost"] = filament['grams_used'] * filament['spool']['cost_per_gram'] - print["total_cost"] += filament["cost"] - break - - total_pages = max(1, math.ceil(total_prints / per_page)) - - return render_template( - 'print_history.html', - prints=prints, - currencysymbol=spoolman_settings["currency_symbol"], - page=page, - total_pages=total_pages, - per_page=per_page, - ) - -@app.route("/print_select_spool") -def print_select_spool(): - - try: - ams_slot = request.args.get("ams_slot") - print_id = request.args.get("print_id") - old_spool_id = request.args.get("old_spool_id") - - change_spool = request.args.get("change_spool", "false").lower() == "true" - - if not old_spool_id: - old_spool_id = -1 - - if not all([ams_slot, print_id]): - return render_template('error.html', exception="Missing spool ID or print ID.") - - spools = mqtt_bambulab.fetchSpools() - - materials = extract_materials(spools) - selected_materials = [] - - filament = print_history_service.get_filament_for_slot(print_id, ams_slot) - - try: - filament_material = filament["filament_type"] if filament else None - - if filament_material and filament_material in materials: - selected_materials.append(filament_material) - except Exception: - pass - - return render_template( - 'print_select_spool.html', - spools=spools, - ams_slot=ams_slot, - print_id=print_id, - old_spool_id=old_spool_id, - change_spool=change_spool, - materials=materials, - selected_materials=selected_materials, - ) - except Exception as e: - traceback.print_exc() + return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") + + tag_id = request.args.get("tag_id") + ams_id = request.args.get("ams") + tray_id = request.args.get("tray") + spool_id = request.args.get("spool_id") + + if not all([ams_id, tray_id, spool_id]): + return render_template('error.html', exception="Missing AMS ID, or Tray ID or spool_id.") + + if READ_ONLY_MODE: + return render_template('error.html', exception="Live read-only mode: assigning spools to trays is disabled.") + + try: + # Update Spoolman with the selected tray + spool_data = spoolman_client.getSpoolById(spool_id) + mqtt_bambulab.setActiveTray(spool_id, spool_data["extra"], ams_id, tray_id) + setActiveSpool(ams_id, tray_id, spool_data) + + return redirect(url_for('home', success_message=f"Updated Spool ID {spool_id} with TAG id {tag_id} to AMS {ams_id}, Tray {tray_id}.")) + except Exception as e: + traceback.print_exc() + return render_template('error.html', exception=str(e)) + +def setActiveSpool(ams_id, tray_id, spool_data): + if USE_TEST_DATA or READ_ONLY_MODE: + return None + + if not mqtt_bambulab.isMqttClientConnected(): + return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") + + ams_message = AMS_FILAMENT_SETTING + ams_message["print"]["sequence_id"] = 0 + ams_message["print"]["ams_id"] = int(ams_id) + ams_message["print"]["tray_id"] = int(tray_id) + + if "color_hex" in spool_data["filament"]: + ams_message["print"]["tray_color"] = spool_data["filament"]["color_hex"].upper() + "FF" + else: + ams_message["print"]["tray_color"] = spool_data["filament"]["multi_color_hexes"].split(',')[0].upper() + "FF" + + if "nozzle_temperature" in spool_data["filament"]["extra"]: + nozzle_temperature_range = spool_data["filament"]["extra"]["nozzle_temperature"].strip("[]").split(",") + ams_message["print"]["nozzle_temp_min"] = int(nozzle_temperature_range[0]) + ams_message["print"]["nozzle_temp_max"] = int(nozzle_temperature_range[1]) + else: + nozzle_temperature_range_obj = generate_filament_temperatures(spool_data["filament"]["material"], + spool_data["filament"]["vendor"]["name"]) + ams_message["print"]["nozzle_temp_min"] = int(nozzle_temperature_range_obj["filament_min_temp"]) + ams_message["print"]["nozzle_temp_max"] = int(nozzle_temperature_range_obj["filament_max_temp"]) + + ams_message["print"]["tray_type"] = spool_data["filament"]["material"] + + filament_brand_code = {} + filament_brand_code["brand_code"] = spool_data["filament"]["extra"].get("filament_id", "").strip('"') + filament_brand_code["sub_brand_code"] = "" + + if filament_brand_code["brand_code"] == "": + filament_brand_code = generate_filament_brand_code(spool_data["filament"]["material"], + spool_data["filament"]["vendor"]["name"], + spool_data["filament"]["extra"].get("type", "")) + + ams_message["print"]["tray_info_idx"] = filament_brand_code["brand_code"] + + # TODO: test sub_brand_code + # ams_message["print"]["tray_sub_brands"] = filament_brand_code["sub_brand_code"] + ams_message["print"]["tray_sub_brands"] = "" + + print(ams_message) + mqtt_bambulab.publish(mqtt_bambulab.getMqttClient(), ams_message) + +@app.route("/") +def home(): + if not mqtt_bambulab.isMqttClientConnected(): + return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") + + try: + last_ams_config = mqtt_bambulab.getLastAMSConfig() + ams_data = last_ams_config.get("ams", []) + vt_tray_data = last_ams_config.get("vt_tray", {}) + spool_list = mqtt_bambulab.fetchSpools() + success_message = request.args.get("success_message") + + issue = False + #TODO: Fix issue when external spool info is reset via bambulab interface + augmentTrayDataWithSpoolMan(spool_list, vt_tray_data, trayUid(EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID)) + issue |= vt_tray_data["issue"] + + for ams in ams_data: + for tray in ams["tray"]: + augmentTrayDataWithSpoolMan(spool_list, tray, trayUid(ams["id"], tray["id"])) + issue |= tray["issue"] + + return render_template('index.html', success_message=success_message, ams_data=ams_data, vt_tray_data=vt_tray_data, issue=issue) + except Exception as e: + traceback.print_exc() + return render_template('error.html', exception=str(e)) + +def sort_spools(spools): + def condition(item): + # Ensure the item has an "extra" key and is a dictionary + if not isinstance(item, dict) or "extra" not in item or not isinstance(item["extra"], dict): + return False + + # Check the specified condition + return item["extra"].get("tag") or item["extra"].get("tag") == "" + + # Sort with the custom condition: False values come first + return sorted(spools, key=lambda spool: bool(condition(spool))) + + +def extract_materials(spools): + materials = set() + + for spool in spools: + filament = None + + if isinstance(spool, dict): + filament = spool.get("filament") + else: + filament = getattr(spool, "filament", None) + + if isinstance(filament, dict): + material = filament.get("material") + else: + material = getattr(filament, "material", None) + + if material: + materials.add(material) + + return sorted(materials) + +@app.route("/assign_tag") +def assign_tag(): + if not mqtt_bambulab.isMqttClientConnected(): + return render_template('error.html', exception="MQTT is disconnected. Is the printer online?") + + try: + spools = sort_spools(mqtt_bambulab.fetchSpools()) + + materials = extract_materials(spools) + selected_materials = [] + requested_material = request.args.get("material") + + if requested_material and requested_material in materials: + selected_materials.append(requested_material) + + return render_template('assign_tag.html', spools=spools, materials=materials, selected_materials=selected_materials) + except Exception as e: + traceback.print_exc() + return render_template('error.html', exception=str(e)) + +@app.route("/write_tag") +def write_tag(): + try: + spool_id = request.args.get("spool_id") + + if not spool_id: + return render_template('error.html', exception="spool ID is required as a query parameter (e.g., ?spool_id=1)") + + myuuid = str(uuid.uuid4()) + + spoolman_client.patchExtraTags(spool_id, {}, { + "tag": json.dumps(myuuid), + }) + return render_template('write_tag.html', myuuid=myuuid) + except Exception as e: + traceback.print_exc() + return render_template('error.html', exception=str(e)) + +@app.route('/health', methods=['GET']) +def health(): + return "OK", 200 + +@app.route("/print_history") +def print_history(): + spoolman_settings = spoolman_service.getSettings() + + try: + page = max(int(request.args.get("page", 1)), 1) + except ValueError: + page = 1 + per_page = 50 + offset = max((page - 1) * per_page, 0) + + ams_slot = request.args.get("ams_slot") + print_id = request.args.get("print_id") + spool_id = request.args.get("spool_id") + old_spool_id = request.args.get("old_spool_id") + + if not old_spool_id: + old_spool_id = -1 + + if READ_ONLY_MODE and all([ams_slot, print_id, spool_id]): + return render_template('error.html', exception="Live read-only mode: updating print-to-spool assignments is disabled.") + + if all([ams_slot, print_id, spool_id]): + filament = print_history_service.get_filament_for_slot(print_id, ams_slot) + print_history_service.update_filament_spool(print_id, ams_slot, spool_id) + + if(filament["spool_id"] != int(spool_id) and (not old_spool_id or (old_spool_id and filament["spool_id"] == int(old_spool_id)))): + if old_spool_id and int(old_spool_id) != -1: + spoolman_client.consumeSpool(old_spool_id, filament["grams_used"] * -1) + + spoolman_client.consumeSpool(spool_id, filament["grams_used"]) + + prints, total_prints = print_history_service.get_prints_with_filament(limit=per_page, offset=offset) + + spool_list = mqtt_bambulab.fetchSpools() + + for print in prints: + print["filament_usage"] = json.loads(print["filament_info"]) + print["total_cost"] = 0 + + for filament in print["filament_usage"]: + if filament["spool_id"]: + for spool in spool_list: + if spool['id'] == filament["spool_id"]: + filament["spool"] = spool + filament["cost"] = filament['grams_used'] * filament['spool']['cost_per_gram'] + print["total_cost"] += filament["cost"] + break + + total_pages = max(1, math.ceil(total_prints / per_page)) + + return render_template( + 'print_history.html', + prints=prints, + currencysymbol=spoolman_settings["currency_symbol"], + page=page, + total_pages=total_pages, + per_page=per_page, + ) + +@app.route("/print_select_spool") +def print_select_spool(): + + try: + ams_slot = request.args.get("ams_slot") + print_id = request.args.get("print_id") + old_spool_id = request.args.get("old_spool_id") + + change_spool = request.args.get("change_spool", "false").lower() == "true" + + if not old_spool_id: + old_spool_id = -1 + + if not all([ams_slot, print_id]): + return render_template('error.html', exception="Missing spool ID or print ID.") + + spools = mqtt_bambulab.fetchSpools() + + materials = extract_materials(spools) + selected_materials = [] + + filament = print_history_service.get_filament_for_slot(print_id, ams_slot) + + try: + filament_material = filament["filament_type"] if filament else None + + if filament_material and filament_material in materials: + selected_materials.append(filament_material) + except Exception: + pass + + return render_template( + 'print_select_spool.html', + spools=spools, + ams_slot=ams_slot, + print_id=print_id, + old_spool_id=old_spool_id, + change_spool=change_spool, + materials=materials, + selected_materials=selected_materials, + ) + except Exception as e: + traceback.print_exc() return render_template('error.html', exception=str(e)) diff --git a/docker-compose.yaml b/docker-compose.yaml index 144f079d..53f12052 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,7 +4,7 @@ services: build: . env_file: "config.env" ports: - - "8001:8001" + - "8001:8000" volumes: - ./logs:/home/app/logs - ./data:/home/app/data diff --git a/mqtt_bambulab.py b/mqtt_bambulab.py index 6062a07d..83edbb34 100644 --- a/mqtt_bambulab.py +++ b/mqtt_bambulab.py @@ -1,7 +1,10 @@ + + import json import ssl import traceback from threading import Thread +from typing import Any, Iterable import paho.mqtt.client as mqtt @@ -15,7 +18,6 @@ from logger import append_to_rotating_file from print_history import insert_print, insert_filament_usage, update_filament_spool from filament_usage_tracker import FilamentUsageTracker - MQTT_CLIENT = {} # Global variable storing MQTT Client MQTT_CLIENT_CONNECTED = False MQTT_KEEPALIVE = 60 @@ -26,6 +28,7 @@ PENDING_PRINT_METADATA = {} FILAMENT_TRACKER = FilamentUsageTracker() +LOG_FILE = "/home/app/logs/mqtt.log" def getPrinterModel(): global PRINTER_ID @@ -67,6 +70,74 @@ def getPrinterModel(): "devicename": device_name } +def identify_ams_model_from_module(module: dict[str, Any]) -> str | None: + """Guess the AMS variant that a version module represents.""" + + product_name = (module.get("product_name") or "").strip().lower() + module_name = (module.get("name") or "").strip().lower() + + if "ams lite" in product_name or module_name.startswith("ams_f1"): + return "AMS Lite" + if "ams 2 pro" in product_name or module_name.startswith("n3f"): + return "AMS 2 Pro" + if "ams ht" in product_name or module_name.startswith("ams_ht"): + return "AMS HT" + if module_name == "ams" or module_name.startswith("ams/"): + return "AMS" + + return None + + +def identify_ams_models_from_modules(modules: Iterable[dict[str, Any]]) -> dict[str, dict[str, Any]]: + """Return per-module metadata, including the detected model when available.""" + + results: dict[str, dict[str, Any]] = {} + for module in modules or []: + name = module.get("name") + if not name: + continue + + results[name] = { + "model": identify_ams_model_from_module(module), + "product_name": module.get("product_name"), + "serial": module.get("sn"), + "hw_ver": module.get("hw_ver"), + } + + return results + + +def extract_ams_id_from_module_name(name: str) -> int | None: + parts = name.split("/") + if len(parts) != 2: + return None + try: + return int(parts[1]) + except ValueError: + return None + + +def identify_ams_models_by_id(modules: Iterable[dict[str, Any]]) -> dict[str, str]: + """Return the detected AMS model per numeric AMS ID (module suffix).""" + + results: dict[str, str] = {} + for module in modules or []: + name = module.get("name") + if not name: + continue + + ams_id = extract_ams_id_from_module_name(name) + if ams_id is None: + continue + + model = identify_ams_model_from_module(module) + if model: + results[str(ams_id)] = model + results[ams_id] = model + + return results + + def num2letter(num): return chr(ord("A") + int(num)) @@ -225,6 +296,18 @@ def on_message(client, userdata, msg): try: data = json.loads(msg.payload.decode()) + info = data.get("info") + if info and info.get("command") == "get_version": + modules = info.get("module", []) + detected = identify_ams_models_from_modules(modules) + models_by_id = identify_ams_models_by_id(modules) + LAST_AMS_CONFIG["get_version"] = { + "info": info, + "modules": modules, + "detected_models": detected, + "models_by_id": models_by_id, + } + if "print" in data: append_to_rotating_file("/home/app/logs/mqtt.log", msg.payload.decode()) @@ -332,6 +415,12 @@ def getLastAMSConfig(): return LAST_AMS_CONFIG +def getDetectedAmsModelsById(): + global LAST_AMS_CONFIG + detected = LAST_AMS_CONFIG.get("get_version", {}).get("models_by_id") or {} + return dict(detected) + + def getMqttClient(): global MQTT_CLIENT return MQTT_CLIENT diff --git a/spoolman_service.py b/spoolman_service.py index 0696ac8c..e0427e9d 100644 --- a/spoolman_service.py +++ b/spoolman_service.py @@ -107,6 +107,12 @@ def augmentTrayDataWithSpoolMan(spool_list, tray_data, tray_id): tray_data["color_mismatch_message"] = "" tray_data["ams_material_missing"] = False tray_data["ams_material_missing_message"] = "" + tray_data["unmapped_bambu_tag"] = "" + tray_data["bambu_material"] = "" + tray_data["bambu_color"] = "" + tray_data["bambu_vendor"] = "" + tray_data["bambu_sub_brand"] = "" + tray_sub_brands_raw = "" def _clean_basic(val: str) -> str: # Normalization for matching: @@ -138,6 +144,8 @@ def _clean_basic(val: str) -> str: tray_data.pop(field, None) return + tray_uuid = str(tray_data.get("tray_uuid") or "") + for spool in spool_list: if spool.get("extra") and spool["extra"].get("active_tray") and spool["extra"]["active_tray"] == json.dumps(tray_id): tray_data["name"] = spool["filament"]["name"] @@ -274,6 +282,19 @@ def _clean_basic(val: str) -> str: if tray_data.get("tray_type") and tray_data["tray_type"] != "" and tray_data["matched"] == False: tray_data["issue"] = True + if not tray_data["matched"] and tray_uuid and tray_uuid != "00000000000000000000000000000000": + tray_data["unmapped_bambu_tag"] = tray_uuid + tray_data["bambu_material"] = (tray_data.get("tray_type") or "").strip() + tray_data["bambu_color"] = normalize_color_hex(tray_data.get("tray_color") or "") + tray_data["bambu_vendor"] = "Bambu Lab" + tray_data["bambu_sub_brand"] = tray_sub_brands_raw.strip() + else: + tray_data["unmapped_bambu_tag"] = "" + tray_data["bambu_material"] = "" + tray_data["bambu_color"] = "" + tray_data["bambu_vendor"] = "" + tray_data["bambu_sub_brand"] = "" + def spendFilaments(printdata): if printdata["ams_mapping"]: ams_mapping = printdata["ams_mapping"] diff --git a/templates/assign_bambu_spool.html b/templates/assign_bambu_spool.html new file mode 100644 index 00000000..bb7a286f --- /dev/null +++ b/templates/assign_bambu_spool.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} + +{% block content %} +

Link Bambu Spool to SpoolMan

+

Bambu tag {{ bambu_tag }} detected in this tray. Pick the matching SpoolMan spool to bind this tag and assign the tray.

+{% with action_bambu_link=True, materials=materials, selected_materials=selected_materials, ams_id=ams_id, tray_id=tray_id, bambu_tag=bambu_tag %}{% include 'fragments/list_spools.html' %}{% endwith %} +{% endblock %} diff --git a/templates/fragments/list_spools.html b/templates/fragments/list_spools.html index 5fed5608..19dfb301 100644 --- a/templates/fragments/list_spools.html +++ b/templates/fragments/list_spools.html @@ -1,5 +1,6 @@ {% set materials = materials or [] %} {% set selected_materials = selected_materials or [] %} +{% set action_bambu_link = action_bambu_link or False %} {% if spools|length == 0 or not spools %} @@ -76,6 +77,14 @@
{% endif %} + {% if action_bambu_link %} + + + Link to Spoolman + + + {% endif %} + {% if action_fill %} diff --git a/templates/fragments/tray.html b/templates/fragments/tray.html index 6602e84d..adf46eca 100644 --- a/templates/fragments/tray.html +++ b/templates/fragments/tray.html @@ -60,6 +60,10 @@ {{ tray_data.spool_material }}{% if tray_data.spool_sub_brand %} - {{ tray_data.spool_sub_brand }}{% endif %} {% if tray_data.vendor %} - {{ tray_data.vendor }}{% endif %} + {% elif tray_data.unmapped_bambu_tag %} +
+ Bambu Lab{% if tray_data.bambu_material %} - {{ tray_data.bambu_material }}{% endif %}{% if tray_data.bambu_sub_brand %} - {{ tray_data.bambu_sub_brand }}{% endif %} +
{% else %}
Empty @@ -112,6 +116,21 @@ {% endif %}
+ {% elif tray_data.unmapped_bambu_tag %} + {% set ams_model = AMS_MODELS_BY_ID.get(ams_id) %} + {% set hide_unmapped_remaining_percent = ams_model == "AMS Lite" %} +
{% endif %} diff --git a/tests/test_ams_model_detection.py b/tests/test_ams_model_detection.py new file mode 100644 index 00000000..652595f7 --- /dev/null +++ b/tests/test_ams_model_detection.py @@ -0,0 +1,43 @@ +import mqtt_bambulab + + +def test_identifies_ams_lite_module(): + module = {"name": "ams_f1/0", "product_name": "AMS Lite"} + assert mqtt_bambulab.identify_ams_model_from_module(module) == "AMS Lite" + + +def test_identifies_ams_2_pro_module(): + module = {"name": "n3f/1", "product_name": "AMS 2 Pro (2)"} + assert mqtt_bambulab.identify_ams_model_from_module(module) == "AMS 2 Pro" + + +def test_identifies_ams_ht_module(): + module = {"name": "ams_ht/0", "product_name": "AMS HT"} + assert mqtt_bambulab.identify_ams_model_from_module(module) == "AMS HT" + + +def test_falls_back_to_ams_when_name_matches(): + module = {"name": "ams/3", "product_name": "AMS (3)"} + assert mqtt_bambulab.identify_ams_model_from_module(module) == "AMS" + + +def test_detects_multiple_modules(): + modules = [ + {"name": "ams_f1/0", "product_name": "AMS Lite"}, + {"name": "n3f/0", "product_name": "AMS 2 Pro (1)"}, + {"name": "ams/0", "product_name": "AMS"}, + ] + results = mqtt_bambulab.identify_ams_models_from_modules(modules) + assert results["ams_f1/0"]["model"] == "AMS Lite" + assert results["n3f/0"]["model"] == "AMS 2 Pro" + assert results["ams/0"]["model"] == "AMS" + + +def test_detects_models_by_numeric_id(): + modules = [ + {"name": "ams_f1/0", "product_name": "AMS Lite"}, + {"name": "n3f/1", "product_name": "AMS 2 Pro (2)"}, + ] + results = mqtt_bambulab.identify_ams_models_by_id(modules) + assert results[0] == "AMS Lite" + assert results[1] == "AMS 2 Pro" diff --git a/tests/test_tray_remaining.py b/tests/test_tray_remaining.py new file mode 100644 index 00000000..610a4318 --- /dev/null +++ b/tests/test_tray_remaining.py @@ -0,0 +1,39 @@ +from jinja2 import Environment, FileSystemLoader + + +def _render_tray(*, tray_data, ams_models_by_id): + env = Environment(loader=FileSystemLoader("templates")) + env.globals.update( + AUTO_SPEND=False, + AMS_MODELS_BY_ID=ams_models_by_id, + SPOOLMAN_BASE_URL="http://spoolman", + color_is_dark=lambda _: False, + url_for=lambda endpoint, **_kwargs: f"/{endpoint}", + ) + template = env.get_template("fragments/tray.html") + return template.render(tray_data=tray_data, ams_id=0, pick_tray=False, tray_id=0) + + +def _unmapped_tray_data(): + return { + "id": 0, + "tray_type": "PLA", + "tray_color": "FFFFFF", + "bambu_material": "PLA", + "bambu_color": "FFFFFF", + "bambu_sub_brand": "Matte", + "remain": 42, + "unmapped_bambu_tag": "C0FFEE", + "last_used": "-", + } + + +def test_unmapped_remaining_hidden_for_ams_lite(): + html = _render_tray(tray_data=_unmapped_tray_data(), ams_models_by_id={0: "AMS Lite"}) + assert "Remaining" not in html + + +def test_unmapped_remaining_shown_for_other_models(): + html = _render_tray(tray_data=_unmapped_tray_data(), ams_models_by_id={0: "AMS"}) + assert "Remaining" in html + assert "42%" in html From 4d313e30b37770cdfc91bd4c4a75e2359852a490 Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Wed, 17 Dec 2025 22:25:43 +0100 Subject: [PATCH 2/6] optimized display of multiple ams --- app.py | 31 +++++++++++++++++++++++++++++-- templates/index.html | 4 +++- templates/spool_info.html | 4 +++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index bae44ba1..86e67bbe 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,7 @@ import os import traceback import uuid +from collections import Counter from flask import Flask, request, render_template, redirect, url_for @@ -46,6 +47,30 @@ def fronted_utilities(): PRINTER_NAME=PRINTER_NAME, ) + +def build_ams_labels(ams_data): + models_by_id = mqtt_bambulab.getDetectedAmsModelsById() + base_labels = [] + for ams in ams_data: + ams_id = ams.get("id") + key = str(ams_id) + base_name = models_by_id.get(key) or models_by_id.get(ams_id) or "AMS" + base_labels.append(base_name) + + totals = Counter(base_labels) + takt = Counter() + labels = {} + + for ams, base_name in zip(ams_data, base_labels): + takt[base_name] += 1 + suffix = f" {takt[base_name]}" if totals[base_name] > 1 else "" + label = f"{base_name}{suffix}" + ams_id = ams.get("id") + labels[str(ams_id)] = label + labels[ams_id] = label + + return labels + @app.route("/issue") def issue(): if not mqtt_bambulab.isMqttClientConnected(): @@ -267,7 +292,8 @@ def spool_info(): break if current_spool: - return render_template('spool_info.html', tag_id=tag_id, current_spool=current_spool, ams_data=ams_data, vt_tray_data=vt_tray_data, issue=issue) + ams_labels = build_ams_labels(ams_data) + return render_template('spool_info.html', tag_id=tag_id, current_spool=current_spool, ams_data=ams_data, vt_tray_data=vt_tray_data, issue=issue, ams_labels=ams_labels) else: return render_template('error.html', exception="Spool not found") except Exception as e: @@ -383,7 +409,8 @@ def home(): augmentTrayDataWithSpoolMan(spool_list, tray, trayUid(ams["id"], tray["id"])) issue |= tray["issue"] - return render_template('index.html', success_message=success_message, ams_data=ams_data, vt_tray_data=vt_tray_data, issue=issue) + ams_labels = build_ams_labels(ams_data) + return render_template('index.html', success_message=success_message, ams_data=ams_data, vt_tray_data=vt_tray_data, issue=issue, ams_labels=ams_labels) except Exception as e: traceback.print_exc() return render_template('error.html', exception=str(e)) diff --git a/templates/index.html b/templates/index.html index db03910e..53306216 100644 --- a/templates/index.html +++ b/templates/index.html @@ -17,7 +17,9 @@
-
AMS{% if ams_data|length > 1 %} {{ ams.id|int +1 }} {% endif %}
+ {% set ams_key = ams.id %} + {% set ams_label = ams_labels.get(ams_key|string) or ams_labels.get(ams_key) or "AMS" %} +
{{ ams_label }}
{{ humidity_display(ams) }}
diff --git a/templates/spool_info.html b/templates/spool_info.html index ef80872b..3b6d6d81 100644 --- a/templates/spool_info.html +++ b/templates/spool_info.html @@ -16,7 +16,9 @@

Assign Tray

-
AMS{% if ams_data|length > 1 %} {{ ams.id|int +1 }} {% endif %}
+ {% set ams_key = ams.id %} + {% set ams_label = ams_labels.get(ams_key|string) or ams_labels.get(ams_key) or "AMS" %} +
{{ ams_label }}
{{ humidity_display(ams) }}
From afd76788e9e8084a79f32acfecaaa91d7e9f6b3b Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Tue, 23 Dec 2025 12:59:38 +0100 Subject: [PATCH 3/6] - an unknown bambu spool clears the assigned spool for this tray - the spool id is now shown in tray view --- app.py | 19 ++-- mqtt_bambulab.py | 185 ++++++++++++++++++-------------- spoolman_service.py | 24 ++++- templates/fragments/tray.html | 2 + templates/issue.html | 22 ++-- test_data.py | 4 +- tests/test_filament_mismatch.py | 4 +- 7 files changed, 160 insertions(+), 100 deletions(-) diff --git a/app.py b/app.py index 79dc3c87..ed760f1c 100644 --- a/app.py +++ b/app.py @@ -78,6 +78,13 @@ def build_ams_labels(ams_data): return labels + +def _augment_tray(spool_list, tray_data, ams_id, tray_id): + augmentTrayDataWithSpoolMan(spool_list, tray_data, ams_id, tray_id) + if tray_data.get("unmapped_bambu_tag"): + spoolman_service.clear_active_spool_for_tray(ams_id, tray_id) + augmentTrayDataWithSpoolMan(spool_list, tray_data, ams_id, tray_id) + @app.route("/issue") def issue(): if not mqtt_bambulab.isMqttClientConnected(): @@ -110,12 +117,12 @@ def issue(): active_spool = None for spool in spool_list: - if spool.get("extra") and spool["extra"].get("active_tray") and spool["extra"]["active_tray"] == json.dumps(trayUid(ams_id, tray_id)): + if spool.get("extra") and spool["extra"].get("active_tray") and spool["extra"].get("active_tray") == json.dumps(trayUid(ams_id, tray_id)): active_spool = spool break if tray_data: - augmentTrayDataWithSpoolMan(spool_list, tray_data, trayUid(ams_id, tray_id)) + _augment_tray(spool_list, tray_data, ams_id, tray_id) #TODO: Determine issue #New bambulab spool @@ -255,12 +262,12 @@ def spool_info(): issue = False #TODO: Fix issue when external spool info is reset via bambulab interface - augmentTrayDataWithSpoolMan(spool_list, vt_tray_data, trayUid(EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID)) + _augment_tray(spool_list, vt_tray_data, EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID) issue |= vt_tray_data.get("issue", False) for ams in ams_data: for tray in ams["tray"]: - augmentTrayDataWithSpoolMan(spool_list, tray, trayUid(ams["id"], tray["id"])) + _augment_tray(spool_list, tray, ams["id"], tray["id"]) issue |= tray.get("issue", False) if not tag_id and not spool_id: @@ -408,12 +415,12 @@ def home(): issue = False #TODO: Fix issue when external spool info is reset via bambulab interface - augmentTrayDataWithSpoolMan(spool_list, vt_tray_data, trayUid(EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID)) + _augment_tray(spool_list, vt_tray_data, EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID) issue |= vt_tray_data["issue"] for ams in ams_data: for tray in ams["tray"]: - augmentTrayDataWithSpoolMan(spool_list, tray, trayUid(ams["id"], tray["id"])) + _augment_tray(spool_list, tray, ams["id"], tray["id"]) issue |= tray["issue"] ams_labels = build_ams_labels(ams_data) diff --git a/mqtt_bambulab.py b/mqtt_bambulab.py index 9c576921..7620f456 100644 --- a/mqtt_bambulab.py +++ b/mqtt_bambulab.py @@ -9,8 +9,8 @@ import paho.mqtt.client as mqtt from config import PRINTER_ID, PRINTER_CODE, PRINTER_IP, AUTO_SPEND, EXTERNAL_SPOOL_ID, TRACK_LAYER_USAGE -from messages import GET_VERSION, PUSH_ALL -from spoolman_service import spendFilaments, setActiveTray, fetchSpools +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 import time import copy @@ -165,45 +165,45 @@ def map_filament(tray_tar): print(f'Filamentchange {len(PENDING_PRINT_METADATA["filamentChanges"])}: Tray {tray_tar}') # Anzahl der erkannten Wechsel - change_count = len(PENDING_PRINT_METADATA["filamentChanges"]) - 1 # -1, weil der erste Eintrag kein Wechsel ist - - filament_order = PENDING_PRINT_METADATA.get("filamentOrder") or {} - ordered_filaments = sorted(filament_order.items(), key=lambda entry: entry[1]) - assigned_trays = PENDING_PRINT_METADATA.setdefault("assigned_trays", []) - filament_assigned = None - if tray_tar not in assigned_trays: - assigned_trays.append(tray_tar) - unique_index = len(assigned_trays) - 1 - if unique_index < len(ordered_filaments): - filament_assigned = ordered_filaments[unique_index][0] - else: - for filamentId, usage_count in filament_order.items(): - if usage_count == change_count: - filament_assigned = filamentId - break - - if filament_assigned is not None: - mapping = PENDING_PRINT_METADATA.setdefault("ams_mapping", []) - filament_idx = int(filament_assigned) - while len(mapping) <= filament_idx: - mapping.append(None) - mapping[filament_idx] = tray_tar - print(f"✅ Tray {tray_tar} assigned to Filament {filament_assigned}") - - for filament, tray in enumerate(mapping): - if tray is None: - continue - print(f" Filament pos: {filament} → Tray {tray}") - - target_filaments = set(filament_order.keys()) - if target_filaments: - assigned_filaments = { - idx for idx, tray in enumerate(PENDING_PRINT_METADATA.get("ams_mapping", [])) - if tray is not None - } - if target_filaments.issubset(assigned_filaments): - print("\n✅ All trays assigned:") - return True + change_count = len(PENDING_PRINT_METADATA["filamentChanges"]) - 1 # -1, weil der erste Eintrag kein Wechsel ist + + filament_order = PENDING_PRINT_METADATA.get("filamentOrder") or {} + ordered_filaments = sorted(filament_order.items(), key=lambda entry: entry[1]) + assigned_trays = PENDING_PRINT_METADATA.setdefault("assigned_trays", []) + filament_assigned = None + if tray_tar not in assigned_trays: + assigned_trays.append(tray_tar) + unique_index = len(assigned_trays) - 1 + if unique_index < len(ordered_filaments): + filament_assigned = ordered_filaments[unique_index][0] + else: + for filamentId, usage_count in filament_order.items(): + if usage_count == change_count: + filament_assigned = filamentId + break + + if filament_assigned is not None: + mapping = PENDING_PRINT_METADATA.setdefault("ams_mapping", []) + filament_idx = int(filament_assigned) + while len(mapping) <= filament_idx: + mapping.append(None) + mapping[filament_idx] = tray_tar + print(f"✅ Tray {tray_tar} assigned to Filament {filament_assigned}") + + for filament, tray in enumerate(mapping): + if tray is None: + continue + print(f" Filament pos: {filament} → Tray {tray}") + + target_filaments = set(filament_order.keys()) + if target_filaments: + assigned_filaments = { + idx for idx, tray in enumerate(PENDING_PRINT_METADATA.get("ams_mapping", [])) + if tray is not None + } + if target_filaments.issubset(assigned_filaments): + print("\n✅ All trays assigned:") + return True return False @@ -232,19 +232,19 @@ def processMessage(data): PENDING_PRINT_METADATA["print_id"] = print_id PENDING_PRINT_METADATA["complete"] = True - for id, filament in PENDING_PRINT_METADATA["filaments"].items(): - parsed_grams = _parse_grams(filament.get("used_g")) - grams_used = parsed_grams if parsed_grams is not None else 0.0 - if TRACK_LAYER_USAGE: - grams_used = 0.0 - insert_filament_usage( - print_id, - filament["type"], - filament["color"], - grams_used, - id, - estimated_grams=parsed_grams, - ) + for id, filament in PENDING_PRINT_METADATA["filaments"].items(): + parsed_grams = _parse_grams(filament.get("used_g")) + grams_used = parsed_grams if parsed_grams is not None else 0.0 + if TRACK_LAYER_USAGE: + grams_used = 0.0 + insert_filament_usage( + print_id, + filament["type"], + filament["color"], + grams_used, + id, + estimated_grams=parsed_grams, + ) #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): @@ -271,23 +271,23 @@ def processMessage(data): if not PENDING_PRINT_METADATA.get("tracking_started"): print_id = insert_print(PENDING_PRINT_METADATA["file"], PRINTER_STATE["print"]["print_type"], PENDING_PRINT_METADATA["image"]) - PENDING_PRINT_METADATA["ams_mapping"] = [] - PENDING_PRINT_METADATA["filamentChanges"] = [] - PENDING_PRINT_METADATA["assigned_trays"] = [] - PENDING_PRINT_METADATA["complete"] = False - PENDING_PRINT_METADATA["print_id"] = print_id + PENDING_PRINT_METADATA["ams_mapping"] = [] + PENDING_PRINT_METADATA["filamentChanges"] = [] + PENDING_PRINT_METADATA["assigned_trays"] = [] + PENDING_PRINT_METADATA["complete"] = False + PENDING_PRINT_METADATA["print_id"] = print_id FILAMENT_TRACKER.start_local_print_from_metadata(PENDING_PRINT_METADATA) - for id, filament in PENDING_PRINT_METADATA["filaments"].items(): - parsed_grams = _parse_grams(filament.get("used_g")) - grams_used = parsed_grams if parsed_grams is not None else 0.0 - if TRACK_LAYER_USAGE: - grams_used = 0.0 - insert_filament_usage( - print_id, - filament["type"], - filament["color"], - grams_used, + for id, filament in PENDING_PRINT_METADATA["filaments"].items(): + parsed_grams = _parse_grams(filament.get("used_g")) + grams_used = parsed_grams if parsed_grams is not None else 0.0 + if TRACK_LAYER_USAGE: + grams_used = 0.0 + insert_filament_usage( + print_id, + filament["type"], + filament["color"], + grams_used, id, estimated_grams=parsed_grams, ) @@ -360,15 +360,32 @@ def processMessage(data): PRINTER_STATE_LAST = copy.deepcopy(PRINTER_STATE) -def publish(client, msg): - result = client.publish(f"device/{PRINTER_ID}/request", json.dumps(msg)) - status = result[0] - if status == 0: - print(f"Sent {msg} to topic device/{PRINTER_ID}/request") - return True - - print(f"Failed to send message to topic device/{PRINTER_ID}/request") - return False +def publish(client, msg): + result = client.publish(f"device/{PRINTER_ID}/request", json.dumps(msg)) + status = result[0] + if status == 0: + print(f"Sent {msg} to topic device/{PRINTER_ID}/request") + return True + + print(f"Failed to send message to topic device/{PRINTER_ID}/request") + return False + + +def clear_ams_tray_assignment(ams_id, tray_id): + if not MQTT_CLIENT: + return + + ams_message = copy.deepcopy(AMS_FILAMENT_SETTING) + ams_message["print"]["ams_id"] = int(ams_id) + ams_message["print"]["tray_id"] = int(tray_id) + ams_message["print"]["tray_color"] = "" + ams_message["print"]["nozzle_temp_min"] = None + ams_message["print"]["nozzle_temp_max"] = None + ams_message["print"]["tray_type"] = "" + ams_message["print"]["setting_id"] = "" + ams_message["print"]["tray_info_idx"] = "" + + publish(MQTT_CLIENT, ams_message) # Inspired by https://github.com/Donkie/Spoolman/issues/217#issuecomment-2303022970 def on_message(client, userdata, msg): @@ -434,10 +451,14 @@ def on_message(client, userdata, msg): # "remaining_weight": tray["remain"] / 100 * tray["tray_weight"] # }) - if not found and tray_uuid == "00000000000000000000000000000000": - print(" - No Spool or non Bambulab Spool!") - elif not found: - print(" - Not found. Update spool tag!") + if not found and tray_uuid == "00000000000000000000000000000000": + print(" - No Spool or non Bambulab Spool!") + elif not found: + print(" - Not found. Update spool tag!") + tray["unmapped_bambu_tag"] = tray_uuid + tray["issue"] = True + clear_active_spool_for_tray(ams['id'], tray['id']) + clear_ams_tray_assignment(ams['id'], tray['id']) except Exception: traceback.print_exc() diff --git a/spoolman_service.py b/spoolman_service.py index db98665d..8a82bbd2 100644 --- a/spoolman_service.py +++ b/spoolman_service.py @@ -8,9 +8,23 @@ import json import spoolman_client + SPOOLS = {} SPOOLMAN_SETTINGS = {} + +def clear_active_spool_for_tray(ams_id: int, tray_id: int) -> None: + """ + Remove any SpoolMan spool that is currently tagged with the given tray UID. + """ + target = json.dumps(trayUid(ams_id, tray_id)) + for spool in fetchSpools(cached=True): + extras = spool.get("extra") or {} + if extras.get("active_tray") == target: + spoolman_client.patchExtraTags(spool["id"], extras, {"active_tray": json.dumps("")}) + spool.setdefault("extra", {})["active_tray"] = json.dumps("") + break + COLOR_DISTANCE_TOLERANCE = 80 currency_symbols = { @@ -97,7 +111,7 @@ def _log_filament_mismatch(tray_data: dict, spool: dict) -> None: except Exception: pass -def augmentTrayDataWithSpoolMan(spool_list, tray_data, tray_id): +def augmentTrayDataWithSpoolMan(spool_list, tray_data, ams_id, tray_id): tray_data["matched"] = False tray_data["mismatch"] = False tray_data["issue"] = False @@ -111,6 +125,7 @@ def augmentTrayDataWithSpoolMan(spool_list, tray_data, tray_id): tray_data["bambu_vendor"] = "" tray_data["bambu_sub_brand"] = "" tray_sub_brands_raw = "" + tray_data["spool_id"] = None def _clean_basic(val: str) -> str: # Normalization for matching: @@ -140,14 +155,18 @@ def _clean_basic(val: str) -> str: "tray_sub_brand", ]: tray_data.pop(field, None) + tray_data["spool_id"] = None return tray_uuid = str(tray_data.get("tray_uuid") or "") + tray_uid = trayUid(ams_id, tray_id) + target_active_tray = json.dumps(tray_uid) for spool in spool_list: - if spool.get("extra") and spool["extra"].get("active_tray") and spool["extra"]["active_tray"] == json.dumps(tray_id): + if spool.get("extra") and spool["extra"].get("active_tray") and spool["extra"]["active_tray"] == target_active_tray: tray_data["name"] = spool["filament"]["name"] tray_data["vendor"] = spool["filament"]["vendor"]["name"] + tray_data["spool_id"] = spool["id"] tray_data["spool_material"] = spool["filament"].get("material", "") tray_data["spool_sub_brand"] = (spool["filament"].get("extra", {}).get("type") or "").replace('"', '').strip() tray_data["remaining_weight"] = spool["remaining_weight"] @@ -286,6 +305,7 @@ def _clean_basic(val: str) -> str: tray_data["bambu_color"] = normalize_color_hex(tray_data.get("tray_color") or "") tray_data["bambu_vendor"] = "Bambu Lab" tray_data["bambu_sub_brand"] = tray_sub_brands_raw.strip() + clear_active_spool_for_tray(ams_id, tray_id) else: tray_data["unmapped_bambu_tag"] = "" tray_data["bambu_material"] = "" diff --git a/templates/fragments/tray.html b/templates/fragments/tray.html index adf46eca..fd25e264 100644 --- a/templates/fragments/tray.html +++ b/templates/fragments/tray.html @@ -57,11 +57,13 @@ {% if tray_data.spool_material %}
+ {% if tray_data.spool_id %}#{{ tray_data.spool_id }} {% endif %} {{ tray_data.spool_material }}{% if tray_data.spool_sub_brand %} - {{ tray_data.spool_sub_brand }}{% endif %} {% if tray_data.vendor %} - {{ tray_data.vendor }}{% endif %}
{% elif tray_data.unmapped_bambu_tag %}
+ Unknown spool detected
Bambu Lab{% if tray_data.bambu_material %} - {{ tray_data.bambu_material }}{% endif %}{% if tray_data.bambu_sub_brand %} - {{ tray_data.bambu_sub_brand }}{% endif %}
{% else %} diff --git a/templates/issue.html b/templates/issue.html index 4352af34..eebec0db 100644 --- a/templates/issue.html +++ b/templates/issue.html @@ -1,5 +1,5 @@ -{% extends 'base.html' %} - +{% extends 'base.html' %} + {% block content %}

Solve issue

{% if tray_data and tray_data.mismatch %} @@ -11,7 +11,17 @@

Solve issue

{% endif %} -{% with current_spool=active_spool %} {% include 'fragments/spool_details.html' %} {% endwith %} - - -{% endblock %} +{% if tray_data and tray_data.unmapped_bambu_tag %} +
+
Unbekannte Bambu-Labs Spule erkannt – entferne die aktuelle Zuordnung und verknüpfe sie mit SpoolMan.
+ + Link to Spoolman + +
+{% endif %} +{% if active_spool %} + {% with current_spool=active_spool %}{% include 'fragments/spool_details.html' %}{% endwith %} +{% endif %} + +{% endblock %} diff --git a/test_data.py b/test_data.py index 5fad4e68..0c670ac9 100644 --- a/test_data.py +++ b/test_data.py @@ -122,11 +122,11 @@ def getLastAMSConfig(): vt_tray = config.get("vt_tray") if vt_tray: - augmentTrayDataWithSpoolMan(spool_list, vt_tray, trayUid(EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID)) + augmentTrayDataWithSpoolMan(spool_list, vt_tray, EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID) for ams in config.get("ams", []): for tray in ams.get("tray", []): - augmentTrayDataWithSpoolMan(spool_list, tray, trayUid(ams.get("id"), tray.get("id"))) + augmentTrayDataWithSpoolMan(spool_list, tray, ams.get("id"), tray.get("id")) return config diff --git a/tests/test_filament_mismatch.py b/tests/test_filament_mismatch.py index 0d111600..059562ca 100644 --- a/tests/test_filament_mismatch.py +++ b/tests/test_filament_mismatch.py @@ -33,11 +33,11 @@ def _make_spool(material, extra_type, tray_id="tray-1", spool_id=1, spool_extra_ } -def _run_case(tray, spool, tray_id="tray-1"): +def _run_case(tray, spool, ams_id=0, tray_id="tray-1"): spool_list = [spool] # avoid file writes during tests svc._log_filament_mismatch = lambda *args, **kwargs: None - svc.augmentTrayDataWithSpoolMan(spool_list, tray, tray_id) + svc.augmentTrayDataWithSpoolMan(spool_list, tray, ams_id, tray_id) return tray From cff12664bee67bc3be90ee1d0bff42158a743622 Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Tue, 23 Dec 2025 13:02:10 +0100 Subject: [PATCH 4/6] - option to enable auto clear of empty trays --- config.py | 1 + mqtt_bambulab.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/config.py b/config.py index 152b6aef..84bd3281 100644 --- a/config.py +++ b/config.py @@ -33,3 +33,4 @@ def _env_to_bool(name: str, default: bool = False) -> bool: "SPOOL_SORTING", "filament.material:asc,filament.vendor.name:asc,filament.name:asc" ) DISABLE_MISMATCH_WARNING = _env_to_bool("DISABLE_MISMATCH_WARNING", False) +CLEAR_ASSIGNMENT_WHEN_EMPTY = _env_to_bool("CLEAR_ASSIGNMENT_WHEN_EMPTY", False) diff --git a/mqtt_bambulab.py b/mqtt_bambulab.py index 7620f456..673a12af 100644 --- a/mqtt_bambulab.py +++ b/mqtt_bambulab.py @@ -8,7 +8,15 @@ import paho.mqtt.client as mqtt -from config import PRINTER_ID, PRINTER_CODE, PRINTER_IP, AUTO_SPEND, EXTERNAL_SPOOL_ID, TRACK_LAYER_USAGE +from config import ( + PRINTER_ID, + PRINTER_CODE, + PRINTER_IP, + AUTO_SPEND, + EXTERNAL_SPOOL_ID, + TRACK_LAYER_USAGE, + CLEAR_ASSIGNMENT_WHEN_EMPTY, +) 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 @@ -453,6 +461,9 @@ def on_message(client, userdata, msg): if not found and tray_uuid == "00000000000000000000000000000000": print(" - No Spool or non Bambulab Spool!") + if CLEAR_ASSIGNMENT_WHEN_EMPTY: + clear_active_spool_for_tray(ams['id'], tray['id']) + clear_ams_tray_assignment(ams['id'], tray['id']) elif not found: print(" - Not found. Update spool tag!") tray["unmapped_bambu_tag"] = tray_uuid From d61461714f86aff73969c59bbfbbe73b38ceba44 Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Tue, 23 Dec 2025 13:32:45 +0100 Subject: [PATCH 5/6] repaired test --- tests/test_filament_mismatch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_filament_mismatch.py b/tests/test_filament_mismatch.py index 059562ca..e52a3ced 100644 --- a/tests/test_filament_mismatch.py +++ b/tests/test_filament_mismatch.py @@ -14,13 +14,13 @@ def _make_tray(tray_type, tray_sub_brands, tray_id="tray-1"): } -def _make_spool(material, extra_type, tray_id="tray-1", spool_id=1, spool_extra_type=None): +def _make_spool(material, extra_type, ams_id=0, tray_id="tray-1", spool_id=1, spool_extra_type=None): return { "id": spool_id, "remaining_weight": 1000, # SpoolMan may carry a type in spool.extra; include it when provided. "extra": { - "active_tray": json.dumps(tray_id), + "active_tray": json.dumps(svc.trayUid(ams_id, tray_id)), **({"type": spool_extra_type if spool_extra_type is not None else extra_type} if (spool_extra_type is not None or extra_type) else {}), }, "filament": { From d619e6d93e5ffc88c84ca933372fa286e2c42917 Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Tue, 23 Dec 2025 14:47:15 +0100 Subject: [PATCH 6/6] update config template and readme --- README.md | 1 + config.env.template | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0cf1ddd3..f7142860 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ SpoolMan can print QR-code stickers for every spool; follow the SpoolMan label g - set `TRACK_LAYER_USAGE` to `True` to switch to per-layer tracking/consumption **while `AUTO_SPEND` is also `True`**. If `AUTO_SPEND` is `False`, all filament tracking remains disabled regardless of `TRACK_LAYER_USAGE`. - set `AUTO_SPEND` to `True` if you want automatic filament usage tracking (see the AUTO SPEND notes below). - set `DISABLE_MISMATCH_WARNING` to `True` to hide mismatch warnings in the UI (mismatches are still detected and logged to `data/filament_mismatch.json`). + - set `CLEAR_ASSIGNMENT_WHEN_EMPTY` to `True` if you want OpenSpoolMan to clear any SpoolMan assignment and reset the AMS tray whenever the printer reports no spool in that slot. - By default, the app reads `data/3d_printer_logs.db` for print history; override it through `OPENSPOOLMAN_PRINT_HISTORY_DB` or via the screenshot helper (which targets `data/demo.db` by default). - Run SpoolMan. diff --git a/config.env.template b/config.env.template index 0c257391..b3552f9d 100644 --- a/config.env.template +++ b/config.env.template @@ -6,5 +6,6 @@ PRINTER_IP= PRINTER_NAME= SPOOLMAN_BASE_URL= AUTO_SPEND=False -TRACK_LAYER_USAGE=False -SPOOL_SORTING=filament.material:asc,filament.vendor.name:asc,filament.name:asc \ No newline at end of file +TRACK_LAYER_USAGE=False +SPOOL_SORTING=filament.material:asc,filament.vendor.name:asc,filament.name:asc +CLEAR_ASSIGNMENT_WHEN_EMPTY=False