Create Bambuddy assignments from InvenTree spools
This commit is contained in:
@@ -47,9 +47,14 @@ FILAMENT_PRINTER_LOCATION_MAP=B1:93,B2:94,B3:95,B4:96
|
||||
# Bambuddy spool field that stores the InvenTree StockItem Batch Code.
|
||||
FILAMENT_BATCH_SOURCE=tag_uid
|
||||
FILAMENT_SYNC_SPOOLS=true
|
||||
FILAMENT_SYNC_ASSIGNMENTS=true
|
||||
FILAMENT_SYNC_LOCATIONS=true
|
||||
FILAMENT_SYNC_USAGE=true
|
||||
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
||||
# Optional. If empty, printer IDs are auto-detected from Bambuddy printer names.
|
||||
FILAMENT_PRINTER_ID_MAP=
|
||||
FILAMENT_ASSIGNMENT_DEFAULT_AMS_ID=0
|
||||
FILAMENT_ASSIGNMENT_START_TRAY_ID=0
|
||||
FILAMENT_USAGE_LIMIT=200
|
||||
FILAMENT_USAGE_SUCCESS_STATUSES=success,completed,complete,done
|
||||
FILAMENT_DEFAULT_MATERIAL=PLA
|
||||
|
||||
22
README.md
22
README.md
@@ -99,9 +99,10 @@ Recommended InvenTree structure for the current setup:
|
||||
|
||||
The service deliberately starts with `FILAMENT_DRY_RUN=true`. In dry-run mode it reads both systems and reports what it would create, move, or subtract, but it does not write filament changes. Switch to `FILAMENT_DRY_RUN=false` only after `/filament/status` and `/sync/filament?dry_run=true` show the expected mapping.
|
||||
|
||||
Filament sync has three independent parts:
|
||||
Filament sync has four independent parts:
|
||||
|
||||
- spool catalog sync: InvenTree stock items create/update Bambuddy spools;
|
||||
- assignment sync: InvenTree printer locations create Bambuddy spool assignments;
|
||||
- location sync: Bambuddy assignments move InvenTree stock to printer locations; returning unassigned loaded spools to storage is optional;
|
||||
- usage sync: Bambuddy usage history subtracts grams from the matching InvenTree stock item.
|
||||
|
||||
@@ -233,9 +234,13 @@ FILAMENT_LOADED_LOCATION_ID=72
|
||||
FILAMENT_PRINTER_LOCATION_MAP=B1:93,B2:94,B3:95,B4:96
|
||||
FILAMENT_BATCH_SOURCE=tag_uid
|
||||
FILAMENT_SYNC_SPOOLS=true
|
||||
FILAMENT_SYNC_ASSIGNMENTS=true
|
||||
FILAMENT_SYNC_LOCATIONS=true
|
||||
FILAMENT_SYNC_USAGE=true
|
||||
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
||||
FILAMENT_PRINTER_ID_MAP=
|
||||
FILAMENT_ASSIGNMENT_DEFAULT_AMS_ID=0
|
||||
FILAMENT_ASSIGNMENT_START_TRAY_ID=0
|
||||
FILAMENT_USAGE_LIMIT=200
|
||||
FILAMENT_USAGE_SUCCESS_STATUSES=success,completed,complete,done
|
||||
FILAMENT_DEFAULT_MATERIAL=PLA
|
||||
@@ -306,6 +311,9 @@ Do not commit `.env`. It contains API tokens and is ignored by git.
|
||||
`FILAMENT_SYNC_SPOOLS`
|
||||
: Creates/updates Bambuddy spool records from InvenTree stock.
|
||||
|
||||
`FILAMENT_SYNC_ASSIGNMENTS`
|
||||
: Creates Bambuddy assignments from InvenTree stock items currently stored in printer locations.
|
||||
|
||||
`FILAMENT_SYNC_LOCATIONS`
|
||||
: Moves InvenTree stock items between storage and printer locations from Bambuddy assignments.
|
||||
|
||||
@@ -315,6 +323,15 @@ Do not commit `.env`. It contains API tokens and is ignored by git.
|
||||
`FILAMENT_RETURN_UNASSIGNED_TO_STORAGE`
|
||||
: When `true`, known Bambuddy spools that are no longer assigned are moved from printer locations back to storage. Keep this `false` until Bambuddy assignments are reliable.
|
||||
|
||||
`FILAMENT_PRINTER_ID_MAP`
|
||||
: Optional explicit printer-name to Bambuddy printer-ID map, for example `B1:5,B2:2,B3:3,B4:4`. If empty, printer IDs are auto-detected from Bambuddy printer names.
|
||||
|
||||
`FILAMENT_ASSIGNMENT_DEFAULT_AMS_ID`
|
||||
: AMS ID used when creating Bambuddy assignments from InvenTree printer locations. Default is `0`.
|
||||
|
||||
`FILAMENT_ASSIGNMENT_START_TRAY_ID`
|
||||
: First tray ID used for each printer when assigning multiple InvenTree-loaded spools. Default is `0`.
|
||||
|
||||
## InvenTree IDs
|
||||
|
||||
Use numeric IDs for target category and stock location. You can find them from the InvenTree UI URL or API.
|
||||
@@ -389,6 +406,7 @@ To also run filament tracking automatically:
|
||||
FILAMENT_TRACKING_ENABLED=true
|
||||
FILAMENT_DRY_RUN=true
|
||||
FILAMENT_SYNC_SPOOLS=true
|
||||
FILAMENT_SYNC_ASSIGNMENTS=true
|
||||
FILAMENT_SYNC_LOCATIONS=true
|
||||
FILAMENT_SYNC_USAGE=true
|
||||
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
||||
@@ -452,6 +470,7 @@ Run individual filament steps:
|
||||
|
||||
```powershell
|
||||
curl.exe -X POST -H "X-Service-Token: change-me" http://localhost:8088/sync/filament/spools
|
||||
curl.exe -X POST -H "X-Service-Token: change-me" http://localhost:8088/sync/filament/assignments
|
||||
curl.exe -X POST -H "X-Service-Token: change-me" http://localhost:8088/sync/filament/locations
|
||||
curl.exe -X POST -H "X-Service-Token: change-me" http://localhost:8088/sync/filament/usage
|
||||
```
|
||||
@@ -467,6 +486,7 @@ POST /sync/archive/{archive_id}
|
||||
POST /sync/backfill
|
||||
POST /sync/filament
|
||||
POST /sync/filament/spools
|
||||
POST /sync/filament/assignments
|
||||
POST /sync/filament/locations
|
||||
POST /sync/filament/usage
|
||||
POST /webhooks/bambuddy
|
||||
|
||||
@@ -70,6 +70,10 @@ class BambuddyClient:
|
||||
data = await self._request("PATCH", f"/inventory/spools/{spool_id}", json=payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def list_printers(self) -> list[dict[str, Any]]:
|
||||
data = await self._request("GET", "/printers/")
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def list_assignments(self, *, printer_id: int | None = None) -> list[dict[str, Any]]:
|
||||
params: dict[str, Any] = {}
|
||||
if printer_id is not None:
|
||||
@@ -77,6 +81,16 @@ class BambuddyClient:
|
||||
data = await self._request("GET", "/inventory/assignments", params=params)
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def assign_spool(self, *, spool_id: int, printer_id: int, ams_id: int, tray_id: int) -> dict[str, Any]:
|
||||
payload = {
|
||||
"spool_id": spool_id,
|
||||
"printer_id": printer_id,
|
||||
"ams_id": ams_id,
|
||||
"tray_id": tray_id,
|
||||
}
|
||||
data = await self._request("POST", "/inventory/assignments", json=payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def list_usage(self, *, limit: int, printer_id: int | None = None) -> list[dict[str, Any]]:
|
||||
params: dict[str, Any] = {"limit": limit}
|
||||
if printer_id is not None:
|
||||
|
||||
@@ -44,9 +44,13 @@ class Settings(BaseSettings):
|
||||
filament_printer_location_map: str = ""
|
||||
filament_batch_source: str = "tag_uid"
|
||||
filament_sync_spools: bool = True
|
||||
filament_sync_assignments: bool = True
|
||||
filament_sync_locations: bool = True
|
||||
filament_sync_usage: bool = True
|
||||
filament_return_unassigned_to_storage: bool = False
|
||||
filament_printer_id_map: str = ""
|
||||
filament_assignment_default_ams_id: Annotated[int, Field(ge=0)] = 0
|
||||
filament_assignment_start_tray_id: Annotated[int, Field(ge=0)] = 0
|
||||
filament_usage_limit: Annotated[int, Field(ge=1, le=1000)] = 200
|
||||
filament_usage_success_statuses: str = "success,completed,complete,done"
|
||||
filament_default_material: str = "PLA"
|
||||
@@ -88,6 +92,19 @@ class Settings(BaseSettings):
|
||||
result[key] = int(value)
|
||||
return result
|
||||
|
||||
@property
|
||||
def filament_printer_ids(self) -> dict[str, int]:
|
||||
result: dict[str, int] = {}
|
||||
for item in self.filament_printer_id_map.split(","):
|
||||
if ":" not in item:
|
||||
continue
|
||||
key, value = item.split(":", 1)
|
||||
key = key.strip().lower()
|
||||
value = value.strip()
|
||||
if key and value.isdigit():
|
||||
result[key] = int(value)
|
||||
return result
|
||||
|
||||
@property
|
||||
def filament_loaded_location_ids(self) -> set[int]:
|
||||
locations = set(self.filament_printer_locations.values())
|
||||
|
||||
@@ -27,11 +27,12 @@ class FilamentTrackingService:
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def status(self) -> dict[str, Any]:
|
||||
stock_items, spools, assignments, usage = await asyncio.gather(
|
||||
stock_items, spools, assignments, usage, printers = await asyncio.gather(
|
||||
self._list_filament_stock(),
|
||||
self.bambuddy.list_spools(),
|
||||
self.bambuddy.list_assignments(),
|
||||
self.bambuddy.list_usage(limit=self.settings.filament_usage_limit),
|
||||
self.bambuddy.list_printers(),
|
||||
)
|
||||
|
||||
stock_by_batch = self._index_stock_by_batch(stock_items)
|
||||
@@ -65,8 +66,11 @@ class FilamentTrackingService:
|
||||
"storage_location_id": self.settings.filament_storage_location_id,
|
||||
"loaded_location_id": self.settings.filament_loaded_location_id,
|
||||
"printer_locations": self.settings.filament_printer_locations,
|
||||
"printer_ids": self.settings.filament_printer_ids,
|
||||
"batch_source": self.settings.filament_batch_source,
|
||||
"return_unassigned_to_storage": self.settings.filament_return_unassigned_to_storage,
|
||||
"assignment_default_ams_id": self.settings.filament_assignment_default_ams_id,
|
||||
"assignment_start_tray_id": self.settings.filament_assignment_start_tray_id,
|
||||
},
|
||||
"inventree": {
|
||||
"stock_items": len(stock_items),
|
||||
@@ -79,6 +83,7 @@ class FilamentTrackingService:
|
||||
"spools_with_batch": len(spools_by_batch),
|
||||
"assignments": len(assignments),
|
||||
"usage_records": len(usage),
|
||||
"printers": len(printers),
|
||||
},
|
||||
"mapping": {
|
||||
"matched_batches": len(matched_batches),
|
||||
@@ -91,6 +96,12 @@ class FilamentTrackingService:
|
||||
"missing_in_bambuddy": missing_in_bambuddy[:50],
|
||||
"missing_in_inventree": missing_in_inventree[:50],
|
||||
"assignments": [self._assignment_summary(item, spools) for item in assignments[:50]],
|
||||
"inventree_loaded_assignments": self._planned_assignment_summaries(
|
||||
stock_items=stock_items,
|
||||
spools_by_batch=spools_by_batch,
|
||||
assignments=assignments,
|
||||
printers=printers,
|
||||
)[:50],
|
||||
"pending_usage": pending_usage[:50],
|
||||
},
|
||||
"database": self.database.counts(),
|
||||
@@ -107,6 +118,8 @@ class FilamentTrackingService:
|
||||
|
||||
if self.settings.filament_sync_spools:
|
||||
result["spools"] = await self.sync_spools_from_inventree(dry_run=dry_run)
|
||||
if self.settings.filament_sync_assignments:
|
||||
result["assignments"] = await self.sync_assignments_from_inventree(dry_run=dry_run)
|
||||
if self.settings.filament_sync_usage:
|
||||
result["usage"] = await self.sync_usage_from_bambuddy(dry_run=dry_run)
|
||||
if self.settings.filament_sync_locations:
|
||||
@@ -168,6 +181,99 @@ class FilamentTrackingService:
|
||||
|
||||
return result
|
||||
|
||||
async def sync_assignments_from_inventree(self, *, dry_run: bool | None = None) -> dict[str, Any]:
|
||||
if not self.settings.filament_tracking_enabled:
|
||||
return {"enabled": False, "status": "disabled"}
|
||||
|
||||
dry = self._dry_run(dry_run)
|
||||
async with self._lock:
|
||||
stock_items, spools, assignments, printers = await asyncio.gather(
|
||||
self._list_filament_stock(),
|
||||
self.bambuddy.list_spools(),
|
||||
self.bambuddy.list_assignments(),
|
||||
self.bambuddy.list_printers(),
|
||||
)
|
||||
spools_by_batch = self._index_spools_by_batch(spools)
|
||||
assigned_by_spool_id = {
|
||||
int(assignment["spool_id"]): assignment
|
||||
for assignment in assignments
|
||||
if assignment.get("spool_id") is not None
|
||||
}
|
||||
assigned_by_slot = {
|
||||
self._assignment_slot_key(assignment): assignment
|
||||
for assignment in assignments
|
||||
if self._assignment_slot_key(assignment) is not None
|
||||
}
|
||||
printer_ids = self._printer_ids_by_key(printers)
|
||||
planned = self._planned_assignments(
|
||||
stock_items=stock_items,
|
||||
spools_by_batch=spools_by_batch,
|
||||
printer_ids=printer_ids,
|
||||
)
|
||||
result: dict[str, Any] = {
|
||||
"dry_run": dry,
|
||||
"seen": len(planned),
|
||||
"created": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"would_create": [],
|
||||
"missing_printers": self._missing_printer_keys(printer_ids),
|
||||
}
|
||||
|
||||
for item in planned:
|
||||
spool_id = int(item["spool_id"])
|
||||
slot_key = (int(item["printer_id"]), int(item["ams_id"]), int(item["tray_id"]))
|
||||
existing_for_spool = assigned_by_spool_id.get(spool_id)
|
||||
existing_for_slot = assigned_by_slot.get(slot_key)
|
||||
|
||||
if existing_for_spool:
|
||||
if self._assignment_slot_key(existing_for_spool) == slot_key:
|
||||
result["unchanged"] += 1
|
||||
else:
|
||||
result["skipped"] += 1
|
||||
result.setdefault("skipped_items", []).append(
|
||||
{**item, "reason": "spool_already_assigned_to_another_slot"}
|
||||
)
|
||||
continue
|
||||
|
||||
if existing_for_slot:
|
||||
result["skipped"] += 1
|
||||
result.setdefault("skipped_items", []).append(
|
||||
{
|
||||
**item,
|
||||
"reason": "target_slot_already_occupied",
|
||||
"current_spool_id": existing_for_slot.get("spool_id"),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if dry:
|
||||
result["would_create"].append(item)
|
||||
continue
|
||||
|
||||
try:
|
||||
created = await self.bambuddy.assign_spool(
|
||||
spool_id=spool_id,
|
||||
printer_id=int(item["printer_id"]),
|
||||
ams_id=int(item["ams_id"]),
|
||||
tray_id=int(item["tray_id"]),
|
||||
)
|
||||
result["created"] += 1
|
||||
assigned_by_spool_id[spool_id] = created or {
|
||||
"spool_id": spool_id,
|
||||
"printer_id": item["printer_id"],
|
||||
"ams_id": item["ams_id"],
|
||||
"tray_id": item["tray_id"],
|
||||
}
|
||||
assigned_by_slot[slot_key] = assigned_by_spool_id[spool_id]
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to assign Bambuddy spool %s to printer %s", spool_id, item["printer_key"])
|
||||
result["failed"] += 1
|
||||
result.setdefault("errors", []).append({**item, "error": str(exc)})
|
||||
|
||||
return result
|
||||
|
||||
async def sync_locations_from_assignments(self, *, dry_run: bool | None = None) -> dict[str, Any]:
|
||||
if not self.settings.filament_tracking_enabled:
|
||||
return {"enabled": False, "status": "disabled"}
|
||||
@@ -486,6 +592,100 @@ class FilamentTrackingService:
|
||||
by_pk[int(item.get("pk") or item.get("id"))] = item
|
||||
return list(by_pk.values())
|
||||
|
||||
def _planned_assignment_summaries(
|
||||
self,
|
||||
*,
|
||||
stock_items: list[dict[str, Any]],
|
||||
spools_by_batch: dict[str, dict[str, Any]],
|
||||
assignments: list[dict[str, Any]],
|
||||
printers: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
printer_ids = self._printer_ids_by_key(printers)
|
||||
existing_spool_ids = {
|
||||
int(assignment["spool_id"])
|
||||
for assignment in assignments
|
||||
if assignment.get("spool_id") is not None
|
||||
}
|
||||
planned = self._planned_assignments(
|
||||
stock_items=stock_items,
|
||||
spools_by_batch=spools_by_batch,
|
||||
printer_ids=printer_ids,
|
||||
)
|
||||
for item in planned:
|
||||
item["already_assigned"] = int(item["spool_id"]) in existing_spool_ids
|
||||
return planned
|
||||
|
||||
def _planned_assignments(
|
||||
self,
|
||||
*,
|
||||
stock_items: list[dict[str, Any]],
|
||||
spools_by_batch: dict[str, dict[str, Any]],
|
||||
printer_ids: dict[str, int],
|
||||
) -> list[dict[str, Any]]:
|
||||
location_to_printer = {
|
||||
location_id: printer_key
|
||||
for printer_key, location_id in self.settings.filament_printer_locations.items()
|
||||
}
|
||||
by_printer: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
for stock_item in stock_items:
|
||||
location_id = self._stock_location(stock_item)
|
||||
printer_key = location_to_printer.get(location_id)
|
||||
if not printer_key:
|
||||
continue
|
||||
by_printer.setdefault(printer_key, []).append(stock_item)
|
||||
|
||||
planned: list[dict[str, Any]] = []
|
||||
for printer_key, items in sorted(by_printer.items()):
|
||||
printer_id = printer_ids.get(printer_key)
|
||||
if printer_id is None:
|
||||
continue
|
||||
|
||||
sorted_items = sorted(items, key=lambda item: int(item.get("pk") or item.get("id") or 0))
|
||||
for index, stock_item in enumerate(sorted_items):
|
||||
batch = self._stock_batch(stock_item)
|
||||
spool = spools_by_batch.get(batch or "")
|
||||
if not batch or not spool or spool.get("id") is None:
|
||||
continue
|
||||
|
||||
planned.append(
|
||||
{
|
||||
"batch": batch,
|
||||
"spool_id": int(spool["id"]),
|
||||
"stock_item_id": int(stock_item.get("pk") or stock_item.get("id")),
|
||||
"printer_key": printer_key,
|
||||
"printer_id": printer_id,
|
||||
"ams_id": self.settings.filament_assignment_default_ams_id,
|
||||
"tray_id": self.settings.filament_assignment_start_tray_id + index,
|
||||
"location_id": self._stock_location(stock_item),
|
||||
"quantity": self._stock_quantity(stock_item),
|
||||
}
|
||||
)
|
||||
|
||||
return planned
|
||||
|
||||
def _printer_ids_by_key(self, printers: list[dict[str, Any]]) -> dict[str, int]:
|
||||
result = dict(self.settings.filament_printer_ids)
|
||||
for printer_key in self.settings.filament_printer_locations:
|
||||
if printer_key in result:
|
||||
continue
|
||||
matched = self._find_printer_for_key(printer_key, printers)
|
||||
if matched and matched.get("id") is not None:
|
||||
result[printer_key] = int(matched["id"])
|
||||
return result
|
||||
|
||||
def _missing_printer_keys(self, printer_ids: dict[str, int]) -> list[str]:
|
||||
return sorted(key for key in self.settings.filament_printer_locations if key not in printer_ids)
|
||||
|
||||
@staticmethod
|
||||
def _find_printer_for_key(printer_key: str, printers: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
normalized_key = printer_key.strip().lower()
|
||||
for printer in printers:
|
||||
name = str(printer.get("name") or "").strip().lower()
|
||||
if name == normalized_key or name.startswith(normalized_key) or normalized_key.startswith(name):
|
||||
return printer
|
||||
return None
|
||||
|
||||
def _target_location_for_assignment(self, assignment: dict[str, Any]) -> int | None:
|
||||
printer_locations = self.settings.filament_printer_locations
|
||||
printer_name = str(assignment.get("printer_name") or "").strip().lower()
|
||||
@@ -509,6 +709,15 @@ class FilamentTrackingService:
|
||||
spool = next((item for item in spools if item.get("id") == spool_id), None)
|
||||
return self._batch_from_spool(spool) if spool else None
|
||||
|
||||
@staticmethod
|
||||
def _assignment_slot_key(assignment: dict[str, Any]) -> tuple[int, int, int] | None:
|
||||
printer_id = assignment.get("printer_id")
|
||||
ams_id = assignment.get("ams_id")
|
||||
tray_id = assignment.get("tray_id")
|
||||
if printer_id is None or ams_id is None or tray_id is None:
|
||||
return None
|
||||
return int(printer_id), int(ams_id), int(tray_id)
|
||||
|
||||
def _spool_payload_for_stock(self, stock_item: dict[str, Any]) -> dict[str, Any]:
|
||||
part = stock_item.get("part_detail") or {}
|
||||
part_name = str(part.get("full_name") or part.get("name") or f"InvenTree stock {stock_item.get('pk')}")
|
||||
|
||||
@@ -206,6 +206,18 @@ async def sync_filament_spools(
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/sync/filament/assignments", dependencies=[Depends(require_service_token)])
|
||||
async def sync_filament_assignments(
|
||||
request: Request,
|
||||
dry_run: bool | None = Query(default=None),
|
||||
) -> dict[str, Any]:
|
||||
filament_service: FilamentTrackingService = request.app.state.filament_service
|
||||
try:
|
||||
return await filament_service.sync_assignments_from_inventree(dry_run=dry_run)
|
||||
except ExternalApiError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/sync/filament/locations", dependencies=[Depends(require_service_token)])
|
||||
async def sync_filament_locations(
|
||||
request: Request,
|
||||
|
||||
Reference in New Issue
Block a user