Skip to content

Commit 0a48096

Browse files
authored
Merge pull request #79 from Xento/main
Update
2 parents 173a4a3 + 653db83 commit 0a48096

10 files changed

Lines changed: 589 additions & 45 deletions

File tree

.github/ISSUE_TEMPLATE/filament-mismatch.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ A clear, short description of the mismatch (what you expected vs. what you saw).
1616
- `DISABLE_MISMATCH_WARNING` set?: ☐ Yes ☐ No
1717

1818
## Data to attach
19-
- `data/filament_mismatch.json` (attach file or paste content)
19+
- `logs/filament_mismatch.json` (attach file or paste content; includes the color difference when relevant)
2020
- screenshot of the mismatch message in the UI
2121

2222
## Additional context

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ SpoolMan can print QR-code stickers for every spool; follow the SpoolMan label g
178178
- set `AUTO_SPEND` to `True` to enable legacy slicer-estimate tracking (no live layer tracking).
179179
- 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`.
180180
- set `AUTO_SPEND` to `True` if you want automatic filament usage tracking (see the AUTO SPEND notes below).
181-
- set `DISABLE_MISMATCH_WARNING` to `True` to hide mismatch warnings in the UI (mismatches are still detected and logged to `data/filament_mismatch.json`).
181+
- set `DISABLE_MISMATCH_WARNING` to `True` to hide mismatch warnings in the UI (mismatches are still detected and logged to `logs/filament_mismatch.json`, including the detected color difference when applicable).
182182
- 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.
183183
- set `COLOR_DISTANCE_TOLERANCE` to an integer (default `40`) if you want to make the perceptual ΔE threshold for tray/spool color mismatch warnings stricter or more lenient; when either side (AMS tray or SpoolMan spool) lacks a color the warning is skipped and the UI shows "Color not set".
184184
- 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).
@@ -204,7 +204,7 @@ SpoolMan can print QR-code stickers for every spool; follow the SpoolMan label g
204204
- `material` = base (e.g., `PLA`) and `type` = the add-on (e.g., `Wood`).
205205
Both must correspond to what the AMS reports for that tray.
206206
- You can wrap optional notes in parentheses inside `material` (e.g., `PLA CF (recycled)`); anything in parentheses is ignored during matching.
207-
- If matching still fails, please file a report using `.github/ISSUE_TEMPLATE/filament-mismatch.md` or temporarily hide the UI warning via `DISABLE_MISMATCH_WARNING=true` (mismatches are still logged to `data/filament_mismatch.json`).
207+
- If matching still fails, please file a report using `.github/ISSUE_TEMPLATE/filament-mismatch.md` or temporarily hide the UI warning via `DISABLE_MISMATCH_WARNING=true` (mismatches are still logged to `logs/filament_mismatch.json`, and color mismatches also capture the computed color distance).
208208

209209
With NFC Tags:
210210
- For non-Bambu filament, select it in SpoolMan, click 'Write,' and tap an NFC tag near your phone (allow NFC).

agents.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Use `docker compose port openspoolman 8001` to see mapped host port if needed.
104104
### Data sources
105105
- Print history DB default: `data/3d_printer_logs.db`
106106
- Override via: `OPENSPOOLMAN_PRINT_HISTORY_DB`
107-
- Mismatch log output: `data/filament_mismatch.json`
107+
- Mismatch log output: `logs/filament_mismatch.json` (now includes the detected color distance when a color mismatch occurs)
108108

109109
### Important operational note
110110
If you change `OPENSPOOLMAN_BASE_URL`, NFC tags must be reconfigured.
@@ -216,7 +216,7 @@ When debugging:
216216
- `PRINTER_IP` reachable from the OpenSpoolMan host/container
217217
- `PRINTER_ACCESS_CODE` correct
218218
- Inspect mismatch log:
219-
- `data/filament_mismatch.json`
219+
- `logs/filament_mismatch.json`
220220
- Confirm print history DB path:
221221
- `data/3d_printer_logs.db` or `OPENSPOOLMAN_PRINT_HISTORY_DB`
222222

api_routes.py

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import json
2+
import os
3+
import traceback
4+
from typing import Any, Dict, List, Optional, Tuple
5+
6+
from flask import Blueprint, jsonify, request
7+
8+
import mqtt_bambulab
9+
import spoolman_client
10+
import spoolman_service
11+
import test_data
12+
from config import EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID, PRINTER_ID, PRINTER_NAME
13+
14+
API_VERSION = "v1"
15+
api_bp = Blueprint("api", __name__, url_prefix=f"/api/{API_VERSION}")
16+
17+
READ_ONLY_MODE = (not test_data.test_data_active()) and os.getenv("OPENSPOOLMAN_LIVE_READONLY") == "1"
18+
ACTIVE_PRINTER_ID = (PRINTER_ID or "").upper() or "PRINTER_1"
19+
20+
21+
def json_success(data: Any, status: int = 200):
22+
return jsonify({"success": True, "data": data}), status
23+
24+
25+
def json_error(code: str, message: str, status: int = 400):
26+
return jsonify({"success": False, "error": {"code": code, "message": message}}), status
27+
28+
29+
def _printer_matches(printer_id: str) -> bool:
30+
return str(printer_id or "").upper() == ACTIVE_PRINTER_ID
31+
32+
33+
def _clean_json_value(value: Any) -> Any:
34+
if isinstance(value, str):
35+
try:
36+
return json.loads(value)
37+
except Exception:
38+
return value
39+
return value
40+
41+
42+
def _serialize_spool(spool: Dict[str, Any]) -> Dict[str, Any]:
43+
filament = spool.get("filament", {}) or {}
44+
extra = spool.get("extra", {}) or {}
45+
46+
tag = _clean_json_value(extra.get("tag"))
47+
48+
assigned_ams_id = None
49+
assigned_tray_index = None
50+
active_tray = extra.get("active_tray")
51+
if active_tray:
52+
try:
53+
tray_uid = json.loads(active_tray)
54+
parts = tray_uid.split("_")
55+
if len(parts) >= 2:
56+
assigned_tray_index = int(parts[-1])
57+
assigned_ams_id = int(parts[-2])
58+
except Exception:
59+
assigned_ams_id = None
60+
assigned_tray_index = None
61+
62+
return {
63+
"id": str(spool.get("id")),
64+
"name": filament.get("name") or spool.get("name") or f"Spool {spool.get('id')}",
65+
"material": filament.get("material") or "",
66+
"vendor": (filament.get("vendor") or {}).get("name"),
67+
"color": filament.get("multi_color_hexes") or filament.get("color_hex") or "",
68+
"diameter_mm": filament.get("diameter"),
69+
"weight_g": spool.get("initial_weight") or filament.get("weight"),
70+
"remaining_g": spool.get("remaining_weight"),
71+
"tag": tag,
72+
"location": spool.get("location"),
73+
"ams_id": assigned_ams_id,
74+
"tray_index": assigned_tray_index,
75+
}
76+
77+
78+
def _find_spool_for_tray(spools: List[Dict[str, Any]], ams_id: int, tray_id: int) -> Optional[Dict[str, Any]]:
79+
tray_uid = spoolman_service.trayUid(ams_id, tray_id)
80+
for spool in spools:
81+
active = _clean_json_value((spool.get("extra") or {}).get("active_tray"))
82+
if active and active == tray_uid:
83+
return spool
84+
return None
85+
86+
87+
def _serialize_tray(tray: Dict[str, Any], spools: List[Dict[str, Any]], ams_id: int) -> Dict[str, Any]:
88+
tray_id = int(tray.get("id") or 0)
89+
matched_spool = _find_spool_for_tray(spools, ams_id, tray_id)
90+
91+
filament = matched_spool.get("filament", {}) if matched_spool else {}
92+
spool_name = filament.get("name") if matched_spool else None
93+
spool_id = matched_spool.get("id") if matched_spool else None
94+
vendor = (filament.get("vendor") or {}).get("name")
95+
material = filament.get("material") or tray.get("tray_type") or ""
96+
97+
tray_color_raw = tray.get("tray_color") or ""
98+
tray_color = spoolman_service.normalize_color_hex(tray_color_raw)
99+
tray_color_value = f"#{tray_color}" if tray_color else ""
100+
101+
spool_color_value = ""
102+
color_mismatch = False
103+
color_mismatch_message = ""
104+
has_multi_color = False
105+
raw_multi_color = filament.get("multi_color_hexes")
106+
if raw_multi_color:
107+
has_multi_color = True
108+
first_color = None
109+
if isinstance(raw_multi_color, list):
110+
first_color = raw_multi_color[0] if raw_multi_color else None
111+
else:
112+
first_color = str(raw_multi_color).split(",")[0]
113+
normalized = spoolman_service.normalize_color_hex(first_color or "")
114+
if normalized:
115+
spool_color_value = f"#{normalized}"
116+
else:
117+
normalized = spoolman_service.normalize_color_hex(filament.get("color_hex") or "")
118+
if normalized:
119+
spool_color_value = f"#{normalized}"
120+
121+
if not has_multi_color and tray_color_value and spool_color_value:
122+
distance = spoolman_service.color_distance(tray_color_value, spool_color_value)
123+
if distance is not None and distance > spoolman_service.COLOR_DISTANCE_TOLERANCE:
124+
color_mismatch = True
125+
color_mismatch_message = "Colors are not similar."
126+
127+
color_value = spool_color_value or tray_color_value
128+
active = bool(tray.get("state") == 3 or matched_spool)
129+
is_loaded = bool(tray.get("remain")) or bool(matched_spool)
130+
remaining_g = None
131+
if matched_spool:
132+
remaining_g = matched_spool.get("remaining_weight")
133+
if remaining_g is None:
134+
remaining_g = matched_spool.get("remain")
135+
else:
136+
remaining_g = tray.get("remain")
137+
138+
return {
139+
"index": tray_id,
140+
"ams_id": ams_id,
141+
"spool_id": spool_id,
142+
"spool_name": spool_name,
143+
"material": material,
144+
"color": color_value,
145+
"tray_color": tray_color_value,
146+
"spool_color": spool_color_value,
147+
"color_mismatch": color_mismatch,
148+
"color_mismatch_message": color_mismatch_message,
149+
"spool_vendor": vendor,
150+
"remaining_g": remaining_g,
151+
"active": active,
152+
"is_loaded": is_loaded,
153+
}
154+
155+
156+
def _load_printer_summary() -> Dict[str, Any]:
157+
model = mqtt_bambulab.getPrinterModel()
158+
name = PRINTER_NAME or model.get("devicename") or "Printer"
159+
160+
return {
161+
"id": ACTIVE_PRINTER_ID,
162+
"name": name,
163+
"online": mqtt_bambulab.isMqttClientConnected(),
164+
"last_seen": None,
165+
}
166+
167+
168+
def _load_trays() -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
169+
config = mqtt_bambulab.getLastAMSConfig() or {}
170+
spools = mqtt_bambulab.fetchSpools()
171+
172+
trays: List[Dict[str, Any]] = []
173+
174+
vt_tray = config.get("vt_tray")
175+
if vt_tray:
176+
trays.append(_serialize_tray(vt_tray, spools, EXTERNAL_SPOOL_AMS_ID))
177+
178+
for ams in config.get("ams", []):
179+
ams_id = int(ams.get("id", 0))
180+
for tray in ams.get("tray", []):
181+
trays.append(_serialize_tray(tray, spools, ams_id))
182+
183+
return trays, config
184+
185+
186+
def _resolve_tray_context(tray_index: int) -> Tuple[Optional[int], Optional[int]]:
187+
config = mqtt_bambulab.getLastAMSConfig() or {}
188+
189+
vt_tray = config.get("vt_tray")
190+
if vt_tray and int(vt_tray.get("id", -1)) == tray_index:
191+
return EXTERNAL_SPOOL_AMS_ID, tray_index
192+
193+
for ams in config.get("ams", []):
194+
ams_id = int(ams.get("id", -1))
195+
for tray in ams.get("tray", []):
196+
if int(tray.get("id", -1)) == tray_index:
197+
return ams_id, tray_index
198+
199+
return None, None
200+
201+
202+
@api_bp.route("/printers", methods=["GET"])
203+
def api_list_printers():
204+
try:
205+
printer = _load_printer_summary()
206+
return json_success([printer])
207+
except Exception as exc:
208+
traceback.print_exc()
209+
return json_error("PRINTER_FETCH_FAILED", f"Failed to load printer info: {exc}", 500)
210+
211+
212+
@api_bp.route("/printers/<printer_id>/ams", methods=["GET"])
213+
def api_get_ams(printer_id: str):
214+
if not _printer_matches(printer_id):
215+
return json_error("PRINTER_NOT_FOUND", f"Printer '{printer_id}' not found", 404)
216+
217+
try:
218+
trays, _ = _load_trays()
219+
payload = {"printer_id": ACTIVE_PRINTER_ID, "ams_slots": trays}
220+
return json_success(payload)
221+
except Exception as exc:
222+
traceback.print_exc()
223+
return json_error("AMS_FETCH_FAILED", f"Failed to fetch AMS data: {exc}", 500)
224+
225+
226+
@api_bp.route("/spools", methods=["GET"])
227+
def api_get_spools():
228+
try:
229+
spools = spoolman_service.fetchSpools()
230+
return json_success([_serialize_spool(spool) for spool in spools])
231+
except Exception as exc:
232+
traceback.print_exc()
233+
return json_error("SPOOL_FETCH_FAILED", f"Failed to fetch spools: {exc}", 500)
234+
235+
236+
@api_bp.route("/printers/<printer_id>/ams/<int:tray_index>/assign", methods=["POST"])
237+
def api_assign_tray(printer_id: str, tray_index: int):
238+
if not _printer_matches(printer_id):
239+
return json_error("PRINTER_NOT_FOUND", f"Printer '{printer_id}' not found", 404)
240+
241+
if READ_ONLY_MODE:
242+
return json_error("READ_ONLY_MODE", "Live read-only mode: assigning spools to trays is disabled.", 403)
243+
244+
if not mqtt_bambulab.isMqttClientConnected():
245+
return json_error("PRINTER_OFFLINE", "MQTT is disconnected. Is the printer online?", 503)
246+
247+
body = request.get_json(silent=True) or {}
248+
spool_id = body.get("spool_id")
249+
250+
if not spool_id:
251+
return json_error("INVALID_REQUEST", "Field 'spool_id' is required.", 400)
252+
253+
ams_id = body.get("ams_id")
254+
if ams_id is None:
255+
ams_id, resolved_tray = _resolve_tray_context(tray_index)
256+
if resolved_tray is None:
257+
return json_error("TRAY_NOT_FOUND", f"Tray '{tray_index}' not found", 404)
258+
else:
259+
try:
260+
ams_id = int(ams_id)
261+
except (TypeError, ValueError):
262+
return json_error("INVALID_REQUEST", "ams_id must be an integer when provided.", 400)
263+
resolved_tray = tray_index
264+
265+
try:
266+
spool_data = spoolman_client.getSpoolById(spool_id)
267+
except Exception as exc:
268+
traceback.print_exc()
269+
return json_error("SPOOL_FETCH_FAILED", f"Failed to fetch spool '{spool_id}': {exc}", 502)
270+
271+
if not spool_data or spool_data.get("id") is None:
272+
return json_error("SPOOL_NOT_FOUND", f"Spool '{spool_id}' not found", 404)
273+
274+
try:
275+
mqtt_bambulab.setActiveTray(spool_id, spool_data.get("extra"), ams_id, resolved_tray)
276+
277+
# Reuse the existing assignment logic from app.setActiveSpool to keep behavior aligned with /fill.
278+
from app import setActiveSpool # Local import to avoid circular dependency at module load time
279+
setActiveSpool(ams_id, resolved_tray, spool_data)
280+
except Exception as exc:
281+
traceback.print_exc()
282+
return json_error("ASSIGN_FAILED", f"Failed to assign spool '{spool_id}' to tray '{tray_index}': {exc}", 500)
283+
284+
return json_success({"printer_id": ACTIVE_PRINTER_ID, "tray_index": tray_index, "ams_id": ams_id, "spool_id": spool_id})
285+
286+
287+
@api_bp.route("/printers/<printer_id>/ams/<int:tray_index>/unassign", methods=["POST"])
288+
def api_unassign_tray(printer_id: str, tray_index: int):
289+
if not _printer_matches(printer_id):
290+
return json_error("PRINTER_NOT_FOUND", f"Printer '{printer_id}' not found", 404)
291+
292+
if READ_ONLY_MODE:
293+
return json_error("READ_ONLY_MODE", "Live read-only mode: assigning spools to trays is disabled.", 403)
294+
295+
body = request.get_json(silent=True) or {}
296+
spool_id = body.get("spool_id")
297+
298+
try:
299+
spool: Optional[Dict[str, Any]] = None
300+
if spool_id:
301+
spool = spoolman_client.getSpoolById(spool_id)
302+
else:
303+
spools = spoolman_service.fetchSpools()
304+
ams_id, _ = _resolve_tray_context(tray_index)
305+
if ams_id is None:
306+
return json_error("TRAY_NOT_FOUND", f"Tray '{tray_index}' not found", 404)
307+
spool = _find_spool_for_tray(spools, ams_id, tray_index)
308+
309+
if not spool or spool.get("id") is None:
310+
return json_error("SPOOL_NOT_FOUND", "No spool assigned to this tray", 404)
311+
312+
extras = spool.get("extra") or {}
313+
spoolman_client.patchExtraTags(spool["id"], extras, {"active_tray": ""})
314+
return json_success(
315+
{
316+
"printer_id": ACTIVE_PRINTER_ID,
317+
"tray_index": tray_index,
318+
"spool_id": spool["id"],
319+
"unassigned": True,
320+
}
321+
)
322+
except Exception as exc:
323+
traceback.print_exc()
324+
return json_error("UNASSIGN_FAILED", f"Failed to unassign tray: {exc}", 500)

app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,3 +716,7 @@ def print_select_spool():
716716
except Exception as e:
717717
traceback.print_exc()
718718
return render_template('error.html', exception=str(e))
719+
720+
# Register REST API blueprint
721+
from api_routes import api_bp
722+
app.register_blueprint(api_bp)

0 commit comments

Comments
 (0)