Merge branch '0.2.0b' into 0.2.0b

This commit is contained in:
MartinNYHC
2026-02-16 08:36:38 +01:00
committed by GitHub
19 changed files with 9366 additions and 8715 deletions
+94 -1
View File
@@ -748,7 +748,7 @@ async def assign_spool(
matching_kp = None
for kp in spool.k_profiles:
if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
continue
matching_kp = kp
break
@@ -954,6 +954,99 @@ async def clear_spool_usage_history(
return {"status": "cleared"}
# ── AMS Weight Sync ──────────────────────────────────────────────────────────
@router.post("/sync-ams-weights")
async def sync_weights_from_ams(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Force-sync spool weight_used from live AMS remain% data.
Overwrites the database weight_used for every assigned spool using the
current AMS remain% from connected printers. This is a manual recovery
tool — it bypasses the normal "only increase" guard.
"""
from backend.app.services.printer_manager import printer_manager
result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
assignments = list(result.scalars().all())
logger.info("AMS weight sync: found %d assignments", len(assignments))
synced = 0
skipped = 0
for assignment in assignments:
spool = assignment.spool
if not spool:
logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
skipped += 1
continue
state = printer_manager.get_status(assignment.printer_id)
if not state or not state.raw_data:
logger.info(
"AMS weight sync: printer %d not connected, skipping spool %d",
assignment.printer_id,
spool.id,
)
skipped += 1
continue
ams_raw = state.raw_data.get("ams", [])
if isinstance(ams_raw, dict):
ams_raw = ams_raw.get("ams", [])
tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
if not tray:
logger.info(
"AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
spool.id,
assignment.printer_id,
assignment.ams_id,
assignment.tray_id,
)
skipped += 1
continue
remain_raw = tray.get("remain")
if remain_raw is None:
logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
skipped += 1
continue
try:
remain_val = int(remain_raw)
except (TypeError, ValueError):
skipped += 1
continue
if remain_val < 0 or remain_val > 100:
logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
skipped += 1
continue
lw = spool.label_weight or 1000
new_used = round(lw * (100 - remain_val) / 100.0, 1)
old_used = spool.weight_used or 0
if round(old_used, 1) != new_used:
logger.info(
"AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
spool.id,
old_used,
new_used,
remain_val,
)
spool.weight_used = new_used
synced += 1
else:
skipped += 1
await db.commit()
return {"synced": synced, "skipped": skipped}
# ── Helpers ──────────────────────────────────────────────────────────────────
+26 -1
View File
@@ -689,7 +689,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
remain_val = int(remain_raw)
except (TypeError, ValueError):
remain_val = -1
if 0 <= remain_val <= 100:
if 1 <= remain_val <= 100:
lw = existing_assignment.spool.label_weight or 1000
new_used = round(lw * (100 - remain_val) / 100.0, 1)
current_used = existing_assignment.spool.weight_used or 0
@@ -2759,6 +2759,31 @@ async def on_print_complete(printer_id: int, data: dict):
logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
log_timing("Queue item update")
# Cleanup: delete uploaded file from printer SD card to prevent phantom prints (Issue #374)
# The print scheduler uploads .3mf files to the SD card root (/). Some printers (e.g. P1S)
# auto-start files found in root on power cycle, causing ghost prints.
try:
printer_info = printer_manager.get_printer(printer_id)
if printer_info and subtask_name:
from backend.app.services.bambu_ftp import delete_file_async
remote_path = f"/{subtask_name}.3mf"
logger.info("Cleaning up uploaded file from printer SD card: %s", remote_path)
delete_result = await delete_file_async(
printer_info.ip_address,
printer_info.access_code,
remote_path,
printer_model=printer_info.model,
)
if delete_result:
logger.info("Deleted %s from printer %s SD card", remote_path, printer_info.name)
else:
logger.debug("File delete returned False for %s (may not exist)", remote_path)
except Exception as e:
logger.debug("SD card file cleanup failed for printer %s: %s (non-critical)", printer_id, e)
log_timing("SD card cleanup")
logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)
+31 -14
View File
@@ -188,7 +188,9 @@ class NotificationService:
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_ntfy(self, config: dict, title: str, message: str) -> tuple[bool, str]:
async def _send_ntfy(
self, config: dict, title: str, message: str, image_data: bytes | None = None
) -> tuple[bool, str]:
"""Send notification via ntfy."""
server = config.get("server", "https://ntfy.sh").rstrip("/")
topic = config.get("topic", "").strip()
@@ -204,7 +206,14 @@ class NotificationService:
headers["Authorization"] = f"Bearer {auth_token}"
client = await self._get_client()
response = await client.post(url, content=message, headers=headers)
if image_data:
# ntfy supports image attachments via multipart form-data
headers["Filename"] = "photo.jpg"
headers["Message"] = message
response = await client.put(url, content=image_data, headers=headers)
else:
response = await client.post(url, content=message, headers=headers)
if response.status_code in (200, 204):
return True, "Message sent successfully"
@@ -257,7 +266,7 @@ class NotificationService:
except Exception:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_telegram(self, config: dict, message: str) -> tuple[bool, str]:
async def _send_telegram(self, config: dict, message: str, image_data: bytes | None = None) -> tuple[bool, str]:
"""Send notification via Telegram bot."""
bot_token = config.get("bot_token", "").strip()
chat_id = config.get("chat_id", "").strip()
@@ -265,8 +274,6 @@ class NotificationService:
if not bot_token or not chat_id:
return False, "Bot token and chat ID are required"
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
# Escape underscores in the message body so Telegram Markdown
# parsing doesn't break on job names like "A1_plate_8" or error
# codes like "0300_0001". The title is already wrapped in *bold*
@@ -276,14 +283,24 @@ class NotificationService:
body_part = body_part.replace("_", "\\_")
message = f"{title_part}\n{body_part}"
data = {
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown",
}
client = await self._get_client()
response = await client.post(url, json=data)
if image_data:
# Use sendPhoto to attach the thumbnail with the caption
url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
response = await client.post(
url,
data={"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"},
files={"photo": ("photo.jpg", image_data, "image/jpeg")},
)
else:
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
data = {
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown",
}
response = await client.post(url, json=data)
if response.status_code == 200:
result = response.json()
@@ -451,11 +468,11 @@ class NotificationService:
if provider.provider_type == "callmebot":
return await self._send_callmebot(config, f"{title}\n{message}")
elif provider.provider_type == "ntfy":
return await self._send_ntfy(config, title, message)
return await self._send_ntfy(config, title, message, image_data=image_data)
elif provider.provider_type == "pushover":
return await self._send_pushover(config, title, message, image_data=image_data)
elif provider.provider_type == "telegram":
return await self._send_telegram(config, f"*{title}*\n{message}")
return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
elif provider.provider_type == "email":
return await self._send_email(config, title, message)
elif provider.provider_type == "discord":