Add filament spool tracking sync
This commit is contained in:
21
.env.example
21
.env.example
@@ -35,5 +35,26 @@ POLL_INTERVAL_SECONDS=300
|
||||
SYNC_ON_STARTUP=false
|
||||
HTTP_TIMEOUT_SECONDS=30
|
||||
|
||||
# Filament tracking. Keep dry-run enabled until /filament/status and
|
||||
# /sync/filament?dry_run=true show the expected actions.
|
||||
FILAMENT_TRACKING_ENABLED=false
|
||||
FILAMENT_DRY_RUN=true
|
||||
FILAMENT_PART_CATEGORY_ID=19
|
||||
FILAMENT_STORAGE_LOCATION_ID=85
|
||||
FILAMENT_LOADED_LOCATION_ID=72
|
||||
# Map Bambuddy printer name prefixes to InvenTree child stock locations.
|
||||
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_LOCATIONS=true
|
||||
FILAMENT_SYNC_USAGE=true
|
||||
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
||||
FILAMENT_USAGE_LIMIT=200
|
||||
FILAMENT_USAGE_SUCCESS_STATUSES=success,completed,complete,done
|
||||
FILAMENT_DEFAULT_MATERIAL=PLA
|
||||
FILAMENT_DEFAULT_LABEL_WEIGHT=1000
|
||||
FILAMENT_DEFAULT_CORE_WEIGHT=250
|
||||
|
||||
# Persistent SQLite database inside the container.
|
||||
DATA_DIR=/data
|
||||
|
||||
164
README.md
164
README.md
@@ -1,6 +1,6 @@
|
||||
# Bambuddy InvenTree Sync
|
||||
|
||||
Sidecar service for syncing successful Bambuddy print archives into InvenTree stock.
|
||||
Sidecar service for syncing successful Bambuddy print archives into InvenTree stock and tracking filament spools between InvenTree and Bambuddy.
|
||||
|
||||
The service runs as a separate Docker container. It does not modify Bambuddy or InvenTree source code. Bambuddy is used as the print-history source, while InvenTree remains the stock-control system of record.
|
||||
|
||||
@@ -17,6 +17,14 @@ For each successful Bambuddy archive, the service:
|
||||
|
||||
Failed, stopped, running, or still-printing archives are skipped when `SYNC_SUCCESS_ONLY=true`.
|
||||
|
||||
For filament, the service can:
|
||||
|
||||
- use InvenTree `StockItem.batch` as the spool identity;
|
||||
- create/update Bambuddy spool records from InvenTree filament stock;
|
||||
- move InvenTree spool stock between storage and printer locations from Bambuddy assignments;
|
||||
- subtract Bambuddy filament usage from the matching InvenTree stock item;
|
||||
- store usage sync state in SQLite to prevent duplicate subtraction.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
@@ -35,6 +43,24 @@ Part + StockItem + Parameters + Image
|
||||
Archive external_url -> InvenTree Part page
|
||||
```
|
||||
|
||||
Filament flow:
|
||||
|
||||
```text
|
||||
InvenTree filament StockItem.batch
|
||||
|
|
||||
| creates or updates Bambuddy spool tag_uid
|
||||
v
|
||||
Bambuddy spool + printer assignment
|
||||
|
|
||||
| stock transfer
|
||||
v
|
||||
InvenTree storage location <-> printer location
|
||||
|
|
||||
| successful Bambuddy usage history
|
||||
v
|
||||
InvenTree stock/remove subtracts filament grams
|
||||
```
|
||||
|
||||
## InvenTree Mapping
|
||||
|
||||
| Bambuddy data | InvenTree target |
|
||||
@@ -49,6 +75,36 @@ Archive external_url -> InvenTree Part page
|
||||
| archive thumbnail | `Part.image` |
|
||||
| InvenTree part page | Bambuddy `external_url` |
|
||||
|
||||
## Filament Tracking
|
||||
|
||||
InvenTree remains the source of truth for spool identity and remaining stock.
|
||||
|
||||
The key rule is:
|
||||
|
||||
```text
|
||||
InvenTree StockItem.batch == Bambuddy Spool.tag_uid
|
||||
```
|
||||
|
||||
Recommended InvenTree structure for the current setup:
|
||||
|
||||
| Purpose | InvenTree ID |
|
||||
| --- | --- |
|
||||
| filament part category | `19` |
|
||||
| filament storage root | `85` |
|
||||
| loaded-in-printers root | `72` |
|
||||
| B1 printer stock location | `93` |
|
||||
| B2 printer stock location | `94` |
|
||||
| B3 printer stock location | `95` |
|
||||
| B4 printer stock location | `96` |
|
||||
|
||||
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:
|
||||
|
||||
- spool catalog sync: InvenTree stock items create/update Bambuddy spools;
|
||||
- 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.
|
||||
|
||||
## Duplicate Protection
|
||||
|
||||
The service is idempotent:
|
||||
@@ -57,6 +113,7 @@ The service is idempotent:
|
||||
- each `StockItem` gets batch `bambuddy-<archive_id>`;
|
||||
- rerunning backfill does not create duplicate stock items;
|
||||
- repeat prints of the same file/model reuse the same `Part` and create new `StockItem` rows.
|
||||
- each Bambuddy filament usage ID is stored before it can subtract stock twice.
|
||||
|
||||
The `Part` identity key is controlled by:
|
||||
|
||||
@@ -84,6 +141,7 @@ This is intentional. InvenTree is the inventory record, and deleting print histo
|
||||
- InvenTree API token.
|
||||
- Existing InvenTree part category ID.
|
||||
- Existing InvenTree stock location ID.
|
||||
- Optional InvenTree filament category and stock location IDs.
|
||||
- Optional InvenTree parameter templates: `Weight` and `PrintTime`.
|
||||
|
||||
On Windows Server 2022, verify Docker with:
|
||||
@@ -166,6 +224,24 @@ BACKFILL_PAGE_SIZE=50
|
||||
POLL_INTERVAL_SECONDS=300
|
||||
SYNC_ON_STARTUP=false
|
||||
HTTP_TIMEOUT_SECONDS=30
|
||||
|
||||
FILAMENT_TRACKING_ENABLED=false
|
||||
FILAMENT_DRY_RUN=true
|
||||
FILAMENT_PART_CATEGORY_ID=19
|
||||
FILAMENT_STORAGE_LOCATION_ID=85
|
||||
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_LOCATIONS=true
|
||||
FILAMENT_SYNC_USAGE=true
|
||||
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
||||
FILAMENT_USAGE_LIMIT=200
|
||||
FILAMENT_USAGE_SUCCESS_STATUSES=success,completed,complete,done
|
||||
FILAMENT_DEFAULT_MATERIAL=PLA
|
||||
FILAMENT_DEFAULT_LABEL_WEIGHT=1000
|
||||
FILAMENT_DEFAULT_CORE_WEIGHT=250
|
||||
|
||||
DATA_DIR=/data
|
||||
```
|
||||
|
||||
@@ -206,6 +282,39 @@ Do not commit `.env`. It contains API tokens and is ignored by git.
|
||||
`OVERWRITE_ARCHIVE_EXTERNAL_LINK`
|
||||
: When `false`, existing non-InvenTree external links in Bambuddy are preserved.
|
||||
|
||||
`FILAMENT_TRACKING_ENABLED`
|
||||
: Enables scheduled and manual filament sync actions.
|
||||
|
||||
`FILAMENT_DRY_RUN`
|
||||
: When `true`, filament endpoints report planned writes but do not create spools, move stock, or subtract stock.
|
||||
|
||||
`FILAMENT_PART_CATEGORY_ID`
|
||||
: InvenTree category containing filament parts.
|
||||
|
||||
`FILAMENT_STORAGE_LOCATION_ID`
|
||||
: InvenTree root location where spare filament spools are stored.
|
||||
|
||||
`FILAMENT_LOADED_LOCATION_ID`
|
||||
: InvenTree root location for spools loaded in printers.
|
||||
|
||||
`FILAMENT_PRINTER_LOCATION_MAP`
|
||||
: Printer prefix to InvenTree location map. Example `B1:93` matches Bambuddy printer names like `B1-X1-CARBON`.
|
||||
|
||||
`FILAMENT_BATCH_SOURCE`
|
||||
: Bambuddy spool field that contains the InvenTree batch code. Default is `tag_uid`.
|
||||
|
||||
`FILAMENT_SYNC_SPOOLS`
|
||||
: Creates/updates Bambuddy spool records from InvenTree stock.
|
||||
|
||||
`FILAMENT_SYNC_LOCATIONS`
|
||||
: Moves InvenTree stock items between storage and printer locations from Bambuddy assignments.
|
||||
|
||||
`FILAMENT_SYNC_USAGE`
|
||||
: Subtracts successful Bambuddy usage history from InvenTree stock.
|
||||
|
||||
`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.
|
||||
|
||||
## InvenTree IDs
|
||||
|
||||
Use numeric IDs for target category and stock location. You can find them from the InvenTree UI URL or API.
|
||||
@@ -231,12 +340,14 @@ After the container starts:
|
||||
```powershell
|
||||
curl.exe http://localhost:8088/health
|
||||
curl.exe -H "X-Service-Token: change-me" http://localhost:8088/validate
|
||||
curl.exe -H "X-Service-Token: change-me" http://localhost:8088/filament/status
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
- `/health` returns `status: ok`;
|
||||
- `/validate` confirms Bambuddy, InvenTree category, and InvenTree location are reachable.
|
||||
- `/filament/status` shows how many InvenTree filament batches match Bambuddy spools.
|
||||
|
||||
## Initial Backfill
|
||||
|
||||
@@ -272,6 +383,35 @@ SYNC_ON_STARTUP=false
|
||||
|
||||
This makes the service check Bambuddy every 5 minutes and import all newly completed prints.
|
||||
|
||||
To also run filament tracking automatically:
|
||||
|
||||
```env
|
||||
FILAMENT_TRACKING_ENABLED=true
|
||||
FILAMENT_DRY_RUN=true
|
||||
FILAMENT_SYNC_SPOOLS=true
|
||||
FILAMENT_SYNC_LOCATIONS=true
|
||||
FILAMENT_SYNC_USAGE=true
|
||||
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
||||
```
|
||||
|
||||
First run in dry-run:
|
||||
|
||||
```powershell
|
||||
curl.exe -X POST -H "X-Service-Token: change-me" "http://localhost:8088/sync/filament?dry_run=true"
|
||||
```
|
||||
|
||||
When the reported actions are correct, change:
|
||||
|
||||
```env
|
||||
FILAMENT_DRY_RUN=false
|
||||
```
|
||||
|
||||
Then rebuild/restart:
|
||||
|
||||
```powershell
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
For near-real-time syncing, also configure a Bambuddy webhook:
|
||||
|
||||
```text
|
||||
@@ -302,14 +442,33 @@ curl.exe -X POST -H "X-Service-Token: change-me" "http://localhost:8088/sync/arc
|
||||
|
||||
Force sync is useful after changing image, parameter, or external link behavior.
|
||||
|
||||
Run all filament sync steps manually:
|
||||
|
||||
```powershell
|
||||
curl.exe -X POST -H "X-Service-Token: change-me" http://localhost:8088/sync/filament
|
||||
```
|
||||
|
||||
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/locations
|
||||
curl.exe -X POST -H "X-Service-Token: change-me" http://localhost:8088/sync/filament/usage
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
```text
|
||||
GET /health
|
||||
GET /validate
|
||||
GET /sync/status
|
||||
GET /filament/status
|
||||
POST /sync/archive/{archive_id}
|
||||
POST /sync/backfill
|
||||
POST /sync/filament
|
||||
POST /sync/filament/spools
|
||||
POST /sync/filament/locations
|
||||
POST /sync/filament/usage
|
||||
POST /webhooks/bambuddy
|
||||
```
|
||||
|
||||
@@ -338,6 +497,9 @@ Common issues:
|
||||
- InvenTree `description` length errors: update to the latest service version; descriptions are capped at 250 characters.
|
||||
- No items imported: check if Bambuddy archives are still `printing` or `failed`.
|
||||
- Duplicate protection prevents repeated imports: use `force=true` only when you want to refresh parameters/images/links for an existing archive.
|
||||
- Filament sync reports `missing_in_bambuddy`: run `/sync/filament/spools?dry_run=true`, then disable dry-run when the generated spool data is correct.
|
||||
- Filament usage stays `pending`: the Bambuddy spool is not linked to an InvenTree batch code in `tag_uid`, or the matching InvenTree stock item is missing.
|
||||
- Location moves do not happen: check `FILAMENT_PRINTER_LOCATION_MAP` and Bambuddy spool assignments.
|
||||
|
||||
## Backups
|
||||
|
||||
|
||||
@@ -58,6 +58,32 @@ class BambuddyClient:
|
||||
data = await self._request("PATCH", f"/archives/{archive_id}", json={"external_url": external_url})
|
||||
return Archive.model_validate(data)
|
||||
|
||||
async def list_spools(self, *, include_archived: bool = False) -> list[dict[str, Any]]:
|
||||
data = await self._request("GET", "/inventory/spools", params={"include_archived": include_archived})
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def create_spool(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = await self._request("POST", "/inventory/spools", json=payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def update_spool(self, spool_id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = await self._request("PATCH", f"/inventory/spools/{spool_id}", json=payload)
|
||||
return data if isinstance(data, dict) 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:
|
||||
params["printer_id"] = printer_id
|
||||
data = await self._request("GET", "/inventory/assignments", params=params)
|
||||
return data if isinstance(data, list) 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:
|
||||
params["printer_id"] = printer_id
|
||||
data = await self._request("GET", "/inventory/usage", params=params)
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def list_archives(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -36,6 +36,23 @@ class Settings(BaseSettings):
|
||||
http_timeout_seconds: Annotated[int, Field(ge=1)] = 30
|
||||
data_dir: Path = Path("/data")
|
||||
|
||||
filament_tracking_enabled: bool = False
|
||||
filament_dry_run: bool = True
|
||||
filament_part_category_id: int | None = None
|
||||
filament_storage_location_id: int | None = None
|
||||
filament_loaded_location_id: int | None = None
|
||||
filament_printer_location_map: str = ""
|
||||
filament_batch_source: str = "tag_uid"
|
||||
filament_sync_spools: bool = True
|
||||
filament_sync_locations: bool = True
|
||||
filament_sync_usage: bool = True
|
||||
filament_return_unassigned_to_storage: bool = False
|
||||
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"
|
||||
filament_default_label_weight: Annotated[int, Field(ge=1)] = 1000
|
||||
filament_default_core_weight: Annotated[int, Field(ge=0)] = 250
|
||||
|
||||
@field_validator("bambuddy_base_url", "inventree_base_url", "inventree_web_url")
|
||||
@classmethod
|
||||
def strip_url(cls, value: str | None) -> str | None:
|
||||
@@ -58,6 +75,31 @@ class Settings(BaseSettings):
|
||||
fields = [field.strip() for field in self.part_key_fields.split(",") if field.strip()]
|
||||
return fields or ["filename", "name"]
|
||||
|
||||
@property
|
||||
def filament_printer_locations(self) -> dict[str, int]:
|
||||
result: dict[str, int] = {}
|
||||
for item in self.filament_printer_location_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())
|
||||
if self.filament_loaded_location_id is not None:
|
||||
locations.add(self.filament_loaded_location_id)
|
||||
return locations
|
||||
|
||||
@property
|
||||
def filament_usage_success_status_names(self) -> set[str]:
|
||||
statuses = [status.strip().lower() for status in self.filament_usage_success_statuses.split(",") if status.strip()]
|
||||
return set(statuses) or {"success", "completed", "complete", "done"}
|
||||
|
||||
@property
|
||||
def inventree_browser_url(self) -> str:
|
||||
base_url = (self.inventree_web_url or self.inventree_base_url).rstrip("/")
|
||||
|
||||
@@ -35,6 +35,20 @@ class Database:
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filament_usage_records (
|
||||
usage_id INTEGER PRIMARY KEY,
|
||||
spool_id INTEGER,
|
||||
batch TEXT,
|
||||
stock_item_id INTEGER,
|
||||
quantity REAL,
|
||||
usage_status TEXT,
|
||||
sync_status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
raw_usage TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -111,6 +125,60 @@ class Database:
|
||||
(part_key, inventree_part_id, display_name),
|
||||
)
|
||||
|
||||
def get_filament_usage_record(self, usage_id: int) -> dict[str, Any] | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM filament_usage_records WHERE usage_id = ?",
|
||||
(usage_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def upsert_filament_usage_record(
|
||||
self,
|
||||
*,
|
||||
usage_id: int,
|
||||
sync_status: str,
|
||||
spool_id: int | None = None,
|
||||
batch: str | None = None,
|
||||
stock_item_id: int | None = None,
|
||||
quantity: float | None = None,
|
||||
usage_status: str | None = None,
|
||||
error: str | None = None,
|
||||
raw_usage: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
raw_usage_json = json.dumps(raw_usage, ensure_ascii=False) if raw_usage is not None else None
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO filament_usage_records (
|
||||
usage_id, spool_id, batch, stock_item_id, quantity,
|
||||
usage_status, sync_status, error, raw_usage
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(usage_id) DO UPDATE SET
|
||||
spool_id = excluded.spool_id,
|
||||
batch = excluded.batch,
|
||||
stock_item_id = excluded.stock_item_id,
|
||||
quantity = excluded.quantity,
|
||||
usage_status = excluded.usage_status,
|
||||
sync_status = excluded.sync_status,
|
||||
error = excluded.error,
|
||||
raw_usage = COALESCE(excluded.raw_usage, filament_usage_records.raw_usage),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
usage_id,
|
||||
spool_id,
|
||||
batch,
|
||||
stock_item_id,
|
||||
quantity,
|
||||
usage_status,
|
||||
sync_status,
|
||||
error,
|
||||
raw_usage_json,
|
||||
),
|
||||
)
|
||||
|
||||
def counts(self) -> dict[str, int]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -121,8 +189,17 @@ class Database:
|
||||
"""
|
||||
).fetchall()
|
||||
parts = conn.execute("SELECT COUNT(*) AS count FROM part_map").fetchone()
|
||||
filament_rows = conn.execute(
|
||||
"""
|
||||
SELECT sync_status, COUNT(*) AS count
|
||||
FROM filament_usage_records
|
||||
GROUP BY sync_status
|
||||
"""
|
||||
).fetchall()
|
||||
result = {row["sync_status"]: int(row["count"]) for row in rows}
|
||||
result["known_parts"] = int(parts["count"]) if parts else 0
|
||||
for row in filament_rows:
|
||||
result[f"filament_usage_{row['sync_status']}"] = int(row["count"])
|
||||
return result
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
|
||||
692
src/bambuddy_inventree_sync/filament.py
Normal file
692
src/bambuddy_inventree_sync/filament.py
Normal file
@@ -0,0 +1,692 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .bambuddy import BambuddyClient
|
||||
from .config import Settings
|
||||
from .database import Database
|
||||
from .inventree import InvenTreeClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FilamentTrackingService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
database: Database,
|
||||
bambuddy: BambuddyClient,
|
||||
inventree: InvenTreeClient,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.database = database
|
||||
self.bambuddy = bambuddy
|
||||
self.inventree = inventree
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def status(self) -> dict[str, Any]:
|
||||
stock_items, spools, assignments, usage = 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),
|
||||
)
|
||||
|
||||
stock_by_batch = self._index_stock_by_batch(stock_items)
|
||||
spools_by_batch = self._index_spools_by_batch(spools)
|
||||
assignment_batches = {
|
||||
batch
|
||||
for batch in (self._batch_for_assignment(assignment, spools) for assignment in assignments)
|
||||
if batch
|
||||
}
|
||||
synced_usage_ids = {
|
||||
int(usage_record["id"])
|
||||
for usage_record in usage
|
||||
if usage_record.get("id") is not None
|
||||
and (self.database.get_filament_usage_record(int(usage_record["id"])) or {}).get("sync_status") == "synced"
|
||||
}
|
||||
|
||||
matched_batches = sorted(set(stock_by_batch).intersection(spools_by_batch))
|
||||
missing_in_bambuddy = [self._stock_summary(item) for batch, item in sorted(stock_by_batch.items()) if batch not in spools_by_batch]
|
||||
missing_in_inventree = [self._spool_summary(item) for batch, item in sorted(spools_by_batch.items()) if batch not in stock_by_batch]
|
||||
pending_usage = [
|
||||
self._usage_summary(item)
|
||||
for item in usage
|
||||
if item.get("id") is not None and int(item["id"]) not in synced_usage_ids
|
||||
]
|
||||
|
||||
return {
|
||||
"enabled": self.settings.filament_tracking_enabled,
|
||||
"dry_run": self.settings.filament_dry_run,
|
||||
"config": {
|
||||
"part_category_id": self.settings.filament_part_category_id,
|
||||
"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,
|
||||
"batch_source": self.settings.filament_batch_source,
|
||||
"return_unassigned_to_storage": self.settings.filament_return_unassigned_to_storage,
|
||||
},
|
||||
"inventree": {
|
||||
"stock_items": len(stock_items),
|
||||
"batches": len(stock_by_batch),
|
||||
"storage_location_id": self.settings.filament_storage_location_id,
|
||||
"loaded_location_id": self.settings.filament_loaded_location_id,
|
||||
},
|
||||
"bambuddy": {
|
||||
"spools": len(spools),
|
||||
"spools_with_batch": len(spools_by_batch),
|
||||
"assignments": len(assignments),
|
||||
"usage_records": len(usage),
|
||||
},
|
||||
"mapping": {
|
||||
"matched_batches": len(matched_batches),
|
||||
"missing_in_bambuddy": len(missing_in_bambuddy),
|
||||
"missing_in_inventree": len(missing_in_inventree),
|
||||
"assigned_batches": len(assignment_batches),
|
||||
"pending_usage_records": len(pending_usage),
|
||||
},
|
||||
"samples": {
|
||||
"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]],
|
||||
"pending_usage": pending_usage[:50],
|
||||
},
|
||||
"database": self.database.counts(),
|
||||
}
|
||||
|
||||
async def sync_all(self, *, dry_run: bool | None = None) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
"enabled": self.settings.filament_tracking_enabled,
|
||||
"dry_run": self._dry_run(dry_run),
|
||||
}
|
||||
if not self.settings.filament_tracking_enabled:
|
||||
result["status"] = "disabled"
|
||||
return result
|
||||
|
||||
if self.settings.filament_sync_spools:
|
||||
result["spools"] = await self.sync_spools_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:
|
||||
result["locations"] = await self.sync_locations_from_assignments(dry_run=dry_run)
|
||||
return result
|
||||
|
||||
async def sync_spools_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 = await asyncio.gather(self._list_filament_stock(), self.bambuddy.list_spools())
|
||||
spools_by_batch = self._index_spools_by_batch(spools)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"dry_run": dry,
|
||||
"seen": len(stock_items),
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"would_create": [],
|
||||
"would_update": [],
|
||||
}
|
||||
|
||||
for stock_item in stock_items:
|
||||
batch = self._stock_batch(stock_item)
|
||||
if not batch:
|
||||
result["skipped"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = self._spool_payload_for_stock(stock_item)
|
||||
existing = spools_by_batch.get(batch)
|
||||
if not existing:
|
||||
if dry:
|
||||
result["would_create"].append({"batch": batch, "stock": self._stock_summary(stock_item), "payload": payload})
|
||||
else:
|
||||
await self.bambuddy.create_spool(payload)
|
||||
result["created"] += 1
|
||||
continue
|
||||
|
||||
patch = self._spool_update_payload(existing, payload)
|
||||
if not patch:
|
||||
result["unchanged"] += 1
|
||||
continue
|
||||
|
||||
if dry:
|
||||
result["would_update"].append({"batch": batch, "spool_id": existing.get("id"), "payload": patch})
|
||||
else:
|
||||
await self.bambuddy.update_spool(int(existing["id"]), patch)
|
||||
result["updated"] += 1
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to sync filament spool for batch %s", batch)
|
||||
result["failed"] += 1
|
||||
result.setdefault("errors", []).append({"batch": batch, "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"}
|
||||
|
||||
dry = self._dry_run(dry_run)
|
||||
async with self._lock:
|
||||
stock_items, spools, assignments = await asyncio.gather(
|
||||
self._list_filament_stock(),
|
||||
self.bambuddy.list_spools(),
|
||||
self.bambuddy.list_assignments(),
|
||||
)
|
||||
stock_by_batch = self._index_stock_by_batch(stock_items)
|
||||
spools_by_batch = self._index_spools_by_batch(spools)
|
||||
assigned_batches: set[str] = set()
|
||||
result: dict[str, Any] = {
|
||||
"dry_run": dry,
|
||||
"assignments": len(assignments),
|
||||
"moved_to_printer": 0,
|
||||
"moved_to_storage": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"would_move": [],
|
||||
}
|
||||
|
||||
for assignment in assignments:
|
||||
batch = self._batch_for_assignment(assignment, spools)
|
||||
if not batch:
|
||||
result["skipped"] += 1
|
||||
continue
|
||||
|
||||
assigned_batches.add(batch)
|
||||
stock_item = stock_by_batch.get(batch) or await self.inventree.find_stock_by_batch_code(
|
||||
batch,
|
||||
part_category_id=self.settings.filament_part_category_id,
|
||||
)
|
||||
target_location_id = self._target_location_for_assignment(assignment)
|
||||
if not stock_item or target_location_id is None:
|
||||
result["skipped"] += 1
|
||||
continue
|
||||
|
||||
await self._move_stock_if_needed(
|
||||
stock_item=stock_item,
|
||||
target_location_id=target_location_id,
|
||||
dry_run=dry,
|
||||
result=result,
|
||||
counter="moved_to_printer",
|
||||
reason=f"Loaded in Bambuddy printer {assignment.get('printer_name') or assignment.get('printer_id')}",
|
||||
)
|
||||
|
||||
storage_location_id = self.settings.filament_storage_location_id
|
||||
if storage_location_id is not None and self.settings.filament_return_unassigned_to_storage:
|
||||
for stock_item in stock_items:
|
||||
batch = self._stock_batch(stock_item)
|
||||
current_location = self._stock_location(stock_item)
|
||||
if (
|
||||
not batch
|
||||
or batch not in spools_by_batch
|
||||
or batch in assigned_batches
|
||||
or current_location not in self.settings.filament_loaded_location_ids
|
||||
):
|
||||
continue
|
||||
|
||||
await self._move_stock_if_needed(
|
||||
stock_item=stock_item,
|
||||
target_location_id=storage_location_id,
|
||||
dry_run=dry,
|
||||
result=result,
|
||||
counter="moved_to_storage",
|
||||
reason="No active Bambuddy assignment",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def sync_usage_from_bambuddy(self, *, dry_run: bool | None = None, force: bool = False) -> 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:
|
||||
usage_records, spools = await asyncio.gather(
|
||||
self.bambuddy.list_usage(limit=self.settings.filament_usage_limit),
|
||||
self.bambuddy.list_spools(include_archived=True),
|
||||
)
|
||||
spools_by_id = {int(spool["id"]): spool for spool in spools if spool.get("id") is not None}
|
||||
result: dict[str, Any] = {
|
||||
"dry_run": dry,
|
||||
"seen": len(usage_records),
|
||||
"removed": 0,
|
||||
"already_synced": 0,
|
||||
"skipped": 0,
|
||||
"pending": 0,
|
||||
"failed": 0,
|
||||
"would_remove": [],
|
||||
}
|
||||
|
||||
for usage in sorted(usage_records, key=lambda item: str(item.get("created_at") or "")):
|
||||
usage_id = usage.get("id")
|
||||
if usage_id is None:
|
||||
result["skipped"] += 1
|
||||
continue
|
||||
|
||||
existing = self.database.get_filament_usage_record(int(usage_id))
|
||||
if existing and existing.get("sync_status") == "synced" and not force:
|
||||
result["already_synced"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
sync_result, detail = await self._sync_usage_record(usage, spools_by_id=spools_by_id, dry_run=dry)
|
||||
if sync_result == "would_remove" and detail:
|
||||
result["would_remove"].append(detail)
|
||||
else:
|
||||
result[sync_result] = result.get(sync_result, 0) + 1
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to sync Bambuddy filament usage %s", usage_id)
|
||||
result["failed"] += 1
|
||||
if not dry:
|
||||
self.database.upsert_filament_usage_record(
|
||||
usage_id=int(usage_id),
|
||||
spool_id=self._usage_spool_id(usage),
|
||||
quantity=self._usage_weight(usage),
|
||||
usage_status=str(usage.get("status") or ""),
|
||||
sync_status="failed",
|
||||
error=str(exc),
|
||||
raw_usage=usage,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def _sync_usage_record(
|
||||
self,
|
||||
usage: dict[str, Any],
|
||||
*,
|
||||
spools_by_id: dict[int, dict[str, Any]],
|
||||
dry_run: bool,
|
||||
) -> tuple[str, dict[str, Any] | None]:
|
||||
usage_id = int(usage["id"])
|
||||
usage_status = str(usage.get("status") or "").lower()
|
||||
if usage_status not in self.settings.filament_usage_success_status_names:
|
||||
if not dry_run:
|
||||
self.database.upsert_filament_usage_record(
|
||||
usage_id=usage_id,
|
||||
spool_id=self._usage_spool_id(usage),
|
||||
quantity=self._usage_weight(usage),
|
||||
usage_status=usage_status,
|
||||
sync_status="skipped",
|
||||
raw_usage=usage,
|
||||
)
|
||||
return "skipped", None
|
||||
|
||||
spool_id = self._usage_spool_id(usage)
|
||||
spool = spools_by_id.get(spool_id) if spool_id is not None else None
|
||||
batch = self._batch_from_spool(spool) if spool else None
|
||||
quantity = self._usage_weight(usage)
|
||||
if not spool or not batch or quantity is None or quantity <= 0:
|
||||
if not dry_run:
|
||||
self.database.upsert_filament_usage_record(
|
||||
usage_id=usage_id,
|
||||
spool_id=spool_id,
|
||||
batch=batch,
|
||||
quantity=quantity,
|
||||
usage_status=usage_status,
|
||||
sync_status="pending",
|
||||
error="Usage record cannot be matched to an InvenTree batch",
|
||||
raw_usage=usage,
|
||||
)
|
||||
return "pending", None
|
||||
|
||||
stock_item = await self.inventree.find_stock_by_batch_code(batch, part_category_id=self.settings.filament_part_category_id)
|
||||
if not stock_item:
|
||||
if not dry_run:
|
||||
self.database.upsert_filament_usage_record(
|
||||
usage_id=usage_id,
|
||||
spool_id=spool_id,
|
||||
batch=batch,
|
||||
quantity=quantity,
|
||||
usage_status=usage_status,
|
||||
sync_status="pending",
|
||||
error="No InvenTree stock item found for batch",
|
||||
raw_usage=usage,
|
||||
)
|
||||
return "pending", None
|
||||
|
||||
stock_item_id = int(stock_item.get("pk") or stock_item.get("id"))
|
||||
current_quantity = self._stock_quantity(stock_item)
|
||||
remove_quantity = min(quantity, current_quantity)
|
||||
if remove_quantity <= 0:
|
||||
if not dry_run:
|
||||
self.database.upsert_filament_usage_record(
|
||||
usage_id=usage_id,
|
||||
spool_id=spool_id,
|
||||
batch=batch,
|
||||
stock_item_id=stock_item_id,
|
||||
quantity=quantity,
|
||||
usage_status=usage_status,
|
||||
sync_status="skipped",
|
||||
error="InvenTree stock quantity is already zero",
|
||||
raw_usage=usage,
|
||||
)
|
||||
return "skipped", None
|
||||
|
||||
notes = (
|
||||
f"Bambuddy filament usage {usage_id}; "
|
||||
f"spool {spool_id}; print {usage.get('print_name') or '-'}; "
|
||||
f"reported {self._format_number(quantity)} g"
|
||||
)
|
||||
if remove_quantity < quantity:
|
||||
notes += f"; capped to available {self._format_number(remove_quantity)} g"
|
||||
|
||||
if dry_run:
|
||||
return "would_remove", {
|
||||
"usage_id": usage_id,
|
||||
"spool_id": spool_id,
|
||||
"batch": batch,
|
||||
"stock_item_id": stock_item_id,
|
||||
"quantity": remove_quantity,
|
||||
"reported_quantity": quantity,
|
||||
"print_name": usage.get("print_name"),
|
||||
}
|
||||
|
||||
await self.inventree.remove_stock_quantity(
|
||||
stock_item_id=stock_item_id,
|
||||
quantity=remove_quantity,
|
||||
notes=notes,
|
||||
)
|
||||
self.database.upsert_filament_usage_record(
|
||||
usage_id=usage_id,
|
||||
spool_id=spool_id,
|
||||
batch=batch,
|
||||
stock_item_id=stock_item_id,
|
||||
quantity=remove_quantity,
|
||||
usage_status=usage_status,
|
||||
sync_status="synced",
|
||||
raw_usage=usage,
|
||||
)
|
||||
logger.info(
|
||||
"Removed %s g from InvenTree filament batch %s using Bambuddy usage %s",
|
||||
self._format_number(remove_quantity),
|
||||
batch,
|
||||
usage_id,
|
||||
)
|
||||
return "removed", None
|
||||
|
||||
async def _move_stock_if_needed(
|
||||
self,
|
||||
*,
|
||||
stock_item: dict[str, Any],
|
||||
target_location_id: int,
|
||||
dry_run: bool,
|
||||
result: dict[str, Any],
|
||||
counter: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
stock_item_id = int(stock_item.get("pk") or stock_item.get("id"))
|
||||
current_location = self._stock_location(stock_item)
|
||||
quantity = self._stock_quantity(stock_item)
|
||||
batch = self._stock_batch(stock_item)
|
||||
|
||||
if current_location == target_location_id:
|
||||
result["unchanged"] += 1
|
||||
return
|
||||
if quantity <= 0:
|
||||
result["skipped"] += 1
|
||||
return
|
||||
|
||||
move = {
|
||||
"batch": batch,
|
||||
"stock_item_id": stock_item_id,
|
||||
"from_location_id": current_location,
|
||||
"to_location_id": target_location_id,
|
||||
"quantity": quantity,
|
||||
"reason": reason,
|
||||
}
|
||||
if dry_run:
|
||||
result["would_move"].append(move)
|
||||
return
|
||||
|
||||
try:
|
||||
await self.inventree.transfer_stock_item(
|
||||
stock_item_id=stock_item_id,
|
||||
location_id=target_location_id,
|
||||
quantity=quantity,
|
||||
notes=f"Bambuddy filament tracking: {reason}",
|
||||
)
|
||||
result[counter] += 1
|
||||
logger.info("Moved filament batch %s stock item %s to location %s", batch, stock_item_id, target_location_id)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to move filament batch %s stock item %s", batch, stock_item_id)
|
||||
result["failed"] += 1
|
||||
result.setdefault("errors", []).append({"batch": batch, "stock_item_id": stock_item_id, "error": str(exc)})
|
||||
|
||||
async def _list_filament_stock(self) -> list[dict[str, Any]]:
|
||||
params_base: dict[str, Any] = {}
|
||||
if self.settings.filament_part_category_id is not None:
|
||||
params_base["part_category"] = self.settings.filament_part_category_id
|
||||
|
||||
locations = [
|
||||
location_id
|
||||
for location_id in (self.settings.filament_storage_location_id, self.settings.filament_loaded_location_id)
|
||||
if location_id is not None
|
||||
]
|
||||
|
||||
by_pk: dict[int, dict[str, Any]] = {}
|
||||
if locations:
|
||||
for location_id in locations:
|
||||
params = dict(params_base)
|
||||
params["location"] = location_id
|
||||
for item in await self.inventree.list_stock_items(params=params):
|
||||
if bool(item.get("in_stock", True)):
|
||||
by_pk[int(item.get("pk") or item.get("id"))] = item
|
||||
return list(by_pk.values())
|
||||
|
||||
if params_base:
|
||||
for item in await self.inventree.list_stock_items(params=params_base):
|
||||
if bool(item.get("in_stock", True)):
|
||||
by_pk[int(item.get("pk") or item.get("id"))] = item
|
||||
return list(by_pk.values())
|
||||
|
||||
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()
|
||||
printer_id = str(assignment.get("printer_id") or "").strip().lower()
|
||||
|
||||
for key, location_id in printer_locations.items():
|
||||
normalized_key = key.strip().lower()
|
||||
if not normalized_key:
|
||||
continue
|
||||
if printer_name == normalized_key or printer_name.startswith(normalized_key):
|
||||
return location_id
|
||||
if printer_id == normalized_key:
|
||||
return location_id
|
||||
|
||||
return self.settings.filament_loaded_location_id
|
||||
|
||||
def _batch_for_assignment(self, assignment: dict[str, Any], spools: list[dict[str, Any]]) -> str | None:
|
||||
spool = assignment.get("spool")
|
||||
if not spool:
|
||||
spool_id = assignment.get("spool_id")
|
||||
spool = next((item for item in spools if item.get("id") == spool_id), None)
|
||||
return self._batch_from_spool(spool) if spool else None
|
||||
|
||||
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')}")
|
||||
material, color_name, brand = self._parse_part_name(part_name)
|
||||
remaining = self._stock_quantity(stock_item)
|
||||
label_weight = self._label_weight_for_stock(remaining)
|
||||
weight_used = max(label_weight - remaining, 0)
|
||||
batch = self._stock_batch(stock_item)
|
||||
|
||||
return {
|
||||
"material": material[:50] or self.settings.filament_default_material,
|
||||
"color_name": color_name,
|
||||
"brand": brand,
|
||||
"label_weight": label_weight,
|
||||
"core_weight": self.settings.filament_default_core_weight,
|
||||
"weight_used": round(weight_used, 3),
|
||||
"slicer_filament": material[:50] or self.settings.filament_default_material,
|
||||
"slicer_filament_name": part_name[:120],
|
||||
"note": f"Synced from InvenTree stock item {stock_item.get('pk')}; batch {batch}",
|
||||
"tag_uid": batch,
|
||||
"data_origin": "inventree",
|
||||
"tag_type": "batch",
|
||||
"weight_locked": True,
|
||||
}
|
||||
|
||||
def _spool_update_payload(self, spool: dict[str, Any], wanted: dict[str, Any]) -> dict[str, Any]:
|
||||
patch: dict[str, Any] = {}
|
||||
for field in (
|
||||
"material",
|
||||
"color_name",
|
||||
"brand",
|
||||
"label_weight",
|
||||
"core_weight",
|
||||
"weight_used",
|
||||
"slicer_filament",
|
||||
"slicer_filament_name",
|
||||
"note",
|
||||
"tag_uid",
|
||||
"data_origin",
|
||||
"tag_type",
|
||||
"weight_locked",
|
||||
):
|
||||
current = spool.get(field)
|
||||
expected = wanted.get(field)
|
||||
if self._values_differ(current, expected):
|
||||
patch[field] = expected
|
||||
return patch
|
||||
|
||||
def _index_stock_by_batch(self, stock_items: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for item in stock_items:
|
||||
batch = self._stock_batch(item)
|
||||
if batch and batch not in result:
|
||||
result[batch] = item
|
||||
return result
|
||||
|
||||
def _index_spools_by_batch(self, spools: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for spool in spools:
|
||||
batch = self._batch_from_spool(spool)
|
||||
if batch and batch not in result:
|
||||
result[batch] = spool
|
||||
return result
|
||||
|
||||
def _batch_from_spool(self, spool: dict[str, Any] | None) -> str | None:
|
||||
if not spool:
|
||||
return None
|
||||
source = self.settings.filament_batch_source.strip() or "tag_uid"
|
||||
value = spool.get(source)
|
||||
if value is None:
|
||||
return None
|
||||
batch = str(value).strip()
|
||||
return batch or None
|
||||
|
||||
@staticmethod
|
||||
def _stock_batch(stock_item: dict[str, Any]) -> str | None:
|
||||
value = stock_item.get("batch")
|
||||
if value is None:
|
||||
return None
|
||||
batch = str(value).strip()
|
||||
return batch or None
|
||||
|
||||
@staticmethod
|
||||
def _stock_location(stock_item: dict[str, Any]) -> int | None:
|
||||
value = stock_item.get("location")
|
||||
return int(value) if value is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _stock_quantity(stock_item: dict[str, Any]) -> float:
|
||||
try:
|
||||
return float(stock_item.get("quantity") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _usage_spool_id(usage: dict[str, Any]) -> int | None:
|
||||
value = usage.get("spool_id")
|
||||
return int(value) if value is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _usage_weight(usage: dict[str, Any]) -> float | None:
|
||||
value = usage.get("weight_used")
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _label_weight_for_stock(self, remaining: float) -> int:
|
||||
default_weight = self.settings.filament_default_label_weight
|
||||
if remaining <= default_weight:
|
||||
return default_weight
|
||||
return int(((remaining + 99) // 100) * 100)
|
||||
|
||||
def _parse_part_name(self, part_name: str) -> tuple[str, str | None, str | None]:
|
||||
tokens = [token for token in re.split(r"[_\-\s]+", part_name.strip()) if token]
|
||||
material = tokens[0] if tokens else self.settings.filament_default_material
|
||||
brand = tokens[-1] if len(tokens) >= 3 else None
|
||||
color_tokens = tokens[1:-1] if brand else tokens[1:]
|
||||
color_name = " ".join(color_tokens).title() if color_tokens else None
|
||||
return material[:50], color_name, brand
|
||||
|
||||
@staticmethod
|
||||
def _values_differ(current: Any, expected: Any) -> bool:
|
||||
if isinstance(expected, float):
|
||||
try:
|
||||
return abs(float(current or 0) - expected) > 0.001
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
return current != expected
|
||||
|
||||
def _dry_run(self, dry_run: bool | None) -> bool:
|
||||
return self.settings.filament_dry_run if dry_run is None else dry_run
|
||||
|
||||
@staticmethod
|
||||
def _format_number(value: float) -> str:
|
||||
return f"{value:.3f}".rstrip("0").rstrip(".")
|
||||
|
||||
def _stock_summary(self, stock_item: dict[str, Any]) -> dict[str, Any]:
|
||||
part = stock_item.get("part_detail") or {}
|
||||
return {
|
||||
"stock_item_id": stock_item.get("pk") or stock_item.get("id"),
|
||||
"part_id": stock_item.get("part"),
|
||||
"part_name": part.get("full_name") or part.get("name"),
|
||||
"batch": self._stock_batch(stock_item),
|
||||
"quantity": self._stock_quantity(stock_item),
|
||||
"location_id": self._stock_location(stock_item),
|
||||
}
|
||||
|
||||
def _spool_summary(self, spool: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"spool_id": spool.get("id"),
|
||||
"batch": self._batch_from_spool(spool),
|
||||
"material": spool.get("material"),
|
||||
"brand": spool.get("brand"),
|
||||
"color_name": spool.get("color_name"),
|
||||
"weight_used": spool.get("weight_used"),
|
||||
}
|
||||
|
||||
def _assignment_summary(self, assignment: dict[str, Any], spools: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {
|
||||
"assignment_id": assignment.get("id"),
|
||||
"spool_id": assignment.get("spool_id"),
|
||||
"batch": self._batch_for_assignment(assignment, spools),
|
||||
"printer_id": assignment.get("printer_id"),
|
||||
"printer_name": assignment.get("printer_name"),
|
||||
"ams_id": assignment.get("ams_id"),
|
||||
"tray_id": assignment.get("tray_id"),
|
||||
"target_location_id": self._target_location_for_assignment(assignment),
|
||||
}
|
||||
|
||||
def _usage_summary(self, usage: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"usage_id": usage.get("id"),
|
||||
"spool_id": usage.get("spool_id"),
|
||||
"print_name": usage.get("print_name"),
|
||||
"weight_used": usage.get("weight_used"),
|
||||
"status": usage.get("status"),
|
||||
"created_at": usage.get("created_at"),
|
||||
}
|
||||
@@ -35,6 +35,9 @@ class InvenTreeClient:
|
||||
async def get_stock_location(self) -> dict[str, Any]:
|
||||
return await self._request("GET", f"/stock/location/{self.settings.inventree_stock_location_id}/")
|
||||
|
||||
async def get_stock_location_by_id(self, location_id: int) -> dict[str, Any]:
|
||||
return await self._request("GET", f"/stock/location/{location_id}/")
|
||||
|
||||
async def get_part(self, part_id: int) -> dict[str, Any]:
|
||||
return await self._request("GET", f"/part/{part_id}/")
|
||||
|
||||
@@ -69,6 +72,39 @@ class InvenTreeClient:
|
||||
return item
|
||||
return None
|
||||
|
||||
async def find_stock_by_batch_code(self, batch: str, *, part_category_id: int | None = None) -> dict[str, Any] | None:
|
||||
params: dict[str, Any] = {"batch": batch, "limit": 100}
|
||||
if part_category_id is not None:
|
||||
params["part_category"] = part_category_id
|
||||
data = await self._request("GET", "/stock/", params=params)
|
||||
for item in self._items(data):
|
||||
if str(item.get("batch") or "") == batch and bool(item.get("in_stock", True)):
|
||||
return item
|
||||
return None
|
||||
|
||||
async def list_stock_items(self, *, params: dict[str, Any] | None = None, limit: int = 100) -> list[dict[str, Any]]:
|
||||
query = dict(params or {})
|
||||
query["limit"] = limit
|
||||
offset = int(query.get("offset") or 0)
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
while True:
|
||||
query["offset"] = offset
|
||||
data = await self._request("GET", "/stock/", params=query)
|
||||
page_items = self._items(data)
|
||||
items.extend(page_items)
|
||||
|
||||
if isinstance(data, dict):
|
||||
total = data.get("count")
|
||||
if total is not None and len(items) >= int(total):
|
||||
break
|
||||
|
||||
if len(page_items) < limit:
|
||||
break
|
||||
offset += len(page_items)
|
||||
|
||||
return items
|
||||
|
||||
async def create_stock_item(self, *, part_id: int, archive: Archive, notes: str) -> dict[str, Any]:
|
||||
quantity = archive.quantity or self.settings.default_stock_quantity
|
||||
payload: dict[str, Any] = {
|
||||
@@ -81,6 +117,36 @@ class InvenTreeClient:
|
||||
}
|
||||
return await self._request("POST", "/stock/", json=payload)
|
||||
|
||||
async def transfer_stock_item(
|
||||
self,
|
||||
*,
|
||||
stock_item_id: int,
|
||||
location_id: int,
|
||||
quantity: float,
|
||||
notes: str,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"items": [{"pk": stock_item_id, "quantity": quantity}],
|
||||
"location": location_id,
|
||||
"notes": notes,
|
||||
}
|
||||
data = await self._request("POST", "/stock/transfer/", json=payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def remove_stock_quantity(
|
||||
self,
|
||||
*,
|
||||
stock_item_id: int,
|
||||
quantity: float,
|
||||
notes: str,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"items": [{"pk": stock_item_id, "quantity": quantity}],
|
||||
"notes": notes,
|
||||
}
|
||||
data = await self._request("POST", "/stock/remove/", json=payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def upload_part_image(self, *, part_id: int, content: bytes, filename: str, content_type: str) -> dict[str, Any]:
|
||||
response = await self.client.patch(
|
||||
f"/part/{part_id}/",
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException, Qu
|
||||
from .bambuddy import BambuddyClient
|
||||
from .config import Settings, get_settings
|
||||
from .database import Database
|
||||
from .filament import FilamentTrackingService
|
||||
from .http_errors import ExternalApiError
|
||||
from .inventree import InvenTreeClient
|
||||
from .models import BambuddyWebhook, SyncResult
|
||||
@@ -48,12 +49,19 @@ async def lifespan(app: FastAPI):
|
||||
bambuddy=bambuddy,
|
||||
inventree=inventree,
|
||||
)
|
||||
filament_service = FilamentTrackingService(
|
||||
settings=settings,
|
||||
database=database,
|
||||
bambuddy=bambuddy,
|
||||
inventree=inventree,
|
||||
)
|
||||
|
||||
app.state.settings = settings
|
||||
app.state.database = database
|
||||
app.state.bambuddy = bambuddy
|
||||
app.state.inventree = inventree
|
||||
app.state.sync_service = sync_service
|
||||
app.state.filament_service = filament_service
|
||||
|
||||
stop_event = asyncio.Event()
|
||||
poll_task: asyncio.Task[Any] | None = None
|
||||
@@ -62,8 +70,9 @@ async def lifespan(app: FastAPI):
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
await sync_service.backfill()
|
||||
await filament_service.sync_all()
|
||||
except Exception:
|
||||
logger.exception("Scheduled backfill failed")
|
||||
logger.exception("Scheduled sync failed")
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=settings.poll_interval_seconds)
|
||||
except TimeoutError:
|
||||
@@ -71,6 +80,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
if settings.sync_on_startup:
|
||||
asyncio.create_task(sync_service.backfill())
|
||||
if settings.filament_tracking_enabled:
|
||||
asyncio.create_task(filament_service.sync_all())
|
||||
|
||||
if settings.poll_interval_seconds > 0:
|
||||
poll_task = asyncio.create_task(poll_loop())
|
||||
@@ -160,3 +171,61 @@ async def sync_backfill(
|
||||
async def sync_status(request: Request) -> dict[str, int]:
|
||||
database: Database = request.app.state.database
|
||||
return database.counts()
|
||||
|
||||
|
||||
@app.get("/filament/status", dependencies=[Depends(require_service_token)])
|
||||
async def filament_status(request: Request) -> dict[str, Any]:
|
||||
filament_service: FilamentTrackingService = request.app.state.filament_service
|
||||
try:
|
||||
return await filament_service.status()
|
||||
except ExternalApiError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/sync/filament", dependencies=[Depends(require_service_token)])
|
||||
async def sync_filament(
|
||||
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_all(dry_run=dry_run)
|
||||
except ExternalApiError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/sync/filament/spools", dependencies=[Depends(require_service_token)])
|
||||
async def sync_filament_spools(
|
||||
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_spools_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,
|
||||
dry_run: bool | None = Query(default=None),
|
||||
) -> dict[str, Any]:
|
||||
filament_service: FilamentTrackingService = request.app.state.filament_service
|
||||
try:
|
||||
return await filament_service.sync_locations_from_assignments(dry_run=dry_run)
|
||||
except ExternalApiError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/sync/filament/usage", dependencies=[Depends(require_service_token)])
|
||||
async def sync_filament_usage(
|
||||
request: Request,
|
||||
dry_run: bool | None = Query(default=None),
|
||||
force: bool = Query(default=False),
|
||||
) -> dict[str, Any]:
|
||||
filament_service: FilamentTrackingService = request.app.state.filament_service
|
||||
try:
|
||||
return await filament_service.sync_usage_from_bambuddy(dry_run=dry_run, force=force)
|
||||
except ExternalApiError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
Reference in New Issue
Block a user