mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 05:01:37 +02:00
@@ -565,6 +565,8 @@ class BackgroundDispatchService:
|
||||
elif base_name.endswith(".3mf"):
|
||||
base_name = base_name[:-4]
|
||||
remote_filename = f"{base_name}.3mf"
|
||||
# Sanitize: firmware parses ftp://{filename} as a URL, spaces break it
|
||||
remote_filename = remote_filename.replace(" ", "_")
|
||||
remote_path = f"/{remote_filename}"
|
||||
|
||||
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
|
||||
@@ -732,6 +734,8 @@ class BackgroundDispatchService:
|
||||
elif base_name.endswith(".3mf"):
|
||||
base_name = base_name[:-4]
|
||||
remote_filename = f"{base_name}.3mf"
|
||||
# Sanitize: firmware parses ftp://{filename} as a URL, spaces break it
|
||||
remote_filename = remote_filename.replace(" ", "_")
|
||||
remote_path = f"/{remote_filename}"
|
||||
|
||||
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
|
||||
|
||||
@@ -10,6 +10,7 @@ but with qos=1 they respond instantly.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
@@ -268,6 +269,8 @@ class BambuMQTTClient:
|
||||
# Class-level cache: serial_number -> False when request topic is known unsupported.
|
||||
# Persists across client instances so reconnects don't re-trigger failed subscriptions.
|
||||
_request_topic_cache: dict[str, bool] = {}
|
||||
# Counter for generating unique MQTT client IDs across instances.
|
||||
_client_instance_counter: int = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -344,6 +347,13 @@ class BambuMQTTClient:
|
||||
self._request_topic_sub_time: float = 0.0
|
||||
self._request_topic_confirmed: bool = False
|
||||
|
||||
# Set when check_staleness() force-closes the socket to trigger reconnect.
|
||||
# Prevents _on_disconnect from redundantly broadcasting state (already done).
|
||||
self._stale_reconnecting: bool = False
|
||||
# Timestamp of last stale reconnect — prevents rapid-fire socket closes
|
||||
# when the frontend polls status faster than paho can reconnect.
|
||||
self._last_stale_reconnect: float = 0.0
|
||||
|
||||
@property
|
||||
def topic_subscribe(self) -> str:
|
||||
return f"device/{self.serial_number}/report"
|
||||
@@ -362,20 +372,47 @@ class BambuMQTTClient:
|
||||
time_since_last = time.time() - self._last_message_time
|
||||
return time_since_last > self.STALE_TIMEOUT
|
||||
|
||||
# Minimum seconds between stale reconnect attempts. Frontend polls
|
||||
# status every few seconds — without a cooldown, each poll would
|
||||
# force-close the socket before paho has time to reconnect.
|
||||
STALE_RECONNECT_COOLDOWN = 30.0
|
||||
|
||||
def check_staleness(self) -> bool:
|
||||
"""Check staleness and update connected state if stale. Returns True if connected."""
|
||||
if self.state.connected and self.is_stale():
|
||||
# Don't force-close again if we already did recently — give paho
|
||||
# time to reconnect and the printer time to send its first message.
|
||||
now = time.time()
|
||||
if now - self._last_stale_reconnect < self.STALE_RECONNECT_COOLDOWN:
|
||||
return self.state.connected
|
||||
|
||||
logger.warning(
|
||||
f"[{self.serial_number}] Connection stale - no message for {time.time() - self._last_message_time:.1f}s"
|
||||
f"[{self.serial_number}] Connection stale - no message for {now - self._last_message_time:.1f}s, forcing reconnect"
|
||||
)
|
||||
self._last_stale_reconnect = now
|
||||
self.state.connected = False
|
||||
if self.on_state_change:
|
||||
self.on_state_change(self.state)
|
||||
# Force-close the underlying socket so paho's loop thread detects
|
||||
# the broken connection and triggers auto-reconnect. We don't call
|
||||
# client.disconnect() because that's a clean disconnect and paho
|
||||
# would NOT auto-reconnect afterwards.
|
||||
# Set flag so _on_disconnect knows this was intentional and skips
|
||||
# redundant state broadcast (we already set connected=False above).
|
||||
self._stale_reconnecting = True
|
||||
if self._client:
|
||||
try:
|
||||
sock = self._client.socket()
|
||||
if sock:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass # Best-effort; paho loop will reconnect on next iteration
|
||||
return self.state.connected
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc, properties=None):
|
||||
if rc == 0:
|
||||
self.state.connected = True
|
||||
self._stale_reconnecting = False # Clear stale-reconnect flag on successful connect
|
||||
# Reset per-connection warning state so warnings fire once per (re)connection
|
||||
self._ams_version_warned = set()
|
||||
client.subscribe(self.topic_subscribe)
|
||||
@@ -433,10 +470,30 @@ class BambuMQTTClient:
|
||||
self._request_topic_sub_time = 0.0
|
||||
|
||||
def _on_disconnect(self, client, userdata, disconnect_flags=None, rc=None, properties=None):
|
||||
# Always unblock disconnect() callers, regardless of whether we suppress
|
||||
# the state broadcast below. disconnect() sets _disconnection_event and
|
||||
# waits on it — every callback path must fire it.
|
||||
if self._disconnection_event:
|
||||
self._disconnection_event.set()
|
||||
|
||||
# If we intentionally closed the socket for stale reconnect, don't broadcast
|
||||
# another state change — check_staleness() already set connected=False and
|
||||
# notified the UI. Just log and let paho auto-reconnect.
|
||||
if self._stale_reconnecting:
|
||||
logger.info(
|
||||
"[%s] Disconnect callback after stale reconnect (expected), rc=%s",
|
||||
self.serial_number,
|
||||
rc,
|
||||
)
|
||||
return
|
||||
|
||||
# Ignore spurious disconnect callbacks if we've received a message recently
|
||||
# Paho-mqtt sometimes fires disconnect callbacks while the connection is still active
|
||||
# Paho-mqtt sometimes fires disconnect callbacks while the connection is still active.
|
||||
# BUT: never suppress error disconnects (keepalive timeout, connection lost, etc.)
|
||||
# — only suppress when rc indicates a clean/normal disconnect.
|
||||
is_error_disconnect = rc is not None and hasattr(rc, "is_failure") and rc.is_failure
|
||||
time_since_last_message = time.time() - self._last_message_time
|
||||
if time_since_last_message < 30.0 and self._last_message_time > 0:
|
||||
if not is_error_disconnect and time_since_last_message < 10.0 and self._last_message_time > 0:
|
||||
logger.debug(
|
||||
f"[{self.serial_number}] Ignoring spurious disconnect (last message {time_since_last_message:.1f}s ago)"
|
||||
)
|
||||
@@ -464,8 +521,6 @@ class BambuMQTTClient:
|
||||
self.state.connected = False
|
||||
if self.on_state_change:
|
||||
self.on_state_change(self.state)
|
||||
if self._disconnection_event:
|
||||
self._disconnection_event.set()
|
||||
|
||||
def _on_message(self, client, userdata, msg):
|
||||
try:
|
||||
@@ -491,10 +546,6 @@ class BambuMQTTClient:
|
||||
self._handle_request_message(payload)
|
||||
return
|
||||
|
||||
# TEMP: Dump full payload once to find extruder state field
|
||||
if not hasattr(self, "_payload_dumped"):
|
||||
self._payload_dumped = True
|
||||
logger.debug("[%s] FULL MQTT PAYLOAD DUMP:\n%s", self.serial_number, json.dumps(payload, indent=2))
|
||||
# Log message if logging is enabled
|
||||
if self._logging_enabled:
|
||||
self._message_log.append(
|
||||
@@ -1358,6 +1409,44 @@ class BambuMQTTClient:
|
||||
# When tray_type is explicitly empty, clear everything
|
||||
# including RFID data (tag_uid/tray_uuid).
|
||||
slot_clearing = new_tray.get("tray_type") == ""
|
||||
# Some printers (e.g. H2D) only send {id, state} in
|
||||
# incremental updates when a tray is not fully loaded.
|
||||
# state=11 means loaded; other values (9=empty,
|
||||
# 10=spool present but filament not in feeder) indicate
|
||||
# the slot should be cleared. Without this, old
|
||||
# tray_type/tray_color persist indefinitely (#784).
|
||||
tray_state = new_tray.get("state")
|
||||
if (
|
||||
tray_state is not None
|
||||
and tray_state != 11
|
||||
and "tray_type" not in new_tray
|
||||
and merged_tray.get("tray_type")
|
||||
):
|
||||
logger.info(
|
||||
"[%s] AMS %s tray %s: state=%s (not loaded) — clearing stale tray data",
|
||||
self.serial_number,
|
||||
ams_id,
|
||||
tray_id,
|
||||
tray_state,
|
||||
)
|
||||
slot_clearing = True
|
||||
# The incremental update only has {id, state} — inject
|
||||
# empty values for all content fields so the merge loop
|
||||
# below clears the stale data from merged_tray.
|
||||
new_tray.update(
|
||||
{
|
||||
"tray_type": "",
|
||||
"tray_sub_brands": "",
|
||||
"tray_color": "",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "",
|
||||
"tag_uid": "0000000000000000",
|
||||
"tray_uuid": "00000000000000000000000000000000",
|
||||
"remain": 0,
|
||||
"k": None,
|
||||
"cali_idx": None,
|
||||
}
|
||||
)
|
||||
for key, value in new_tray.items():
|
||||
# Fields that should always be updated (even with empty/zero values):
|
||||
# - remain, k, id, cali_idx: status indicators where 0 is valid
|
||||
@@ -2416,13 +2505,20 @@ class BambuMQTTClient:
|
||||
):
|
||||
should_trigger_completion = True
|
||||
|
||||
# Log when we see a terminal state but DON'T trigger completion (diagnostics)
|
||||
if not should_trigger_completion and self.state.state in ("FINISH", "FAILED"):
|
||||
# Log when we FIRST see a terminal state but DON'T trigger completion (diagnostics)
|
||||
# Only log on the transition (prev != current) to avoid flooding logs every MQTT update
|
||||
if (
|
||||
not should_trigger_completion
|
||||
and self.state.state in ("FINISH", "FAILED")
|
||||
and self._previous_gcode_state != self.state.state
|
||||
):
|
||||
logger.info(
|
||||
f"[{self.serial_number}] State is {self.state.state} but completion NOT triggered: "
|
||||
f"prev={self._previous_gcode_state}, was_running={self._was_running}, "
|
||||
f"already_triggered={self._completion_triggered}, has_callback={bool(self.on_print_complete)}"
|
||||
)
|
||||
# Mark as triggered so state is clean for the next print cycle
|
||||
self._completion_triggered = True
|
||||
|
||||
if should_trigger_completion:
|
||||
if self.state.state == "FINISH":
|
||||
@@ -2551,9 +2647,11 @@ class BambuMQTTClient:
|
||||
If not provided, will try to get the running loop.
|
||||
"""
|
||||
self._loop = loop
|
||||
BambuMQTTClient._client_instance_counter += 1
|
||||
client_id = f"bambuddy_{self.serial_number}_{os.getpid()}_{BambuMQTTClient._client_instance_counter}"
|
||||
self._client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=f"bambuddy_{self.serial_number}",
|
||||
client_id=client_id,
|
||||
protocol=mqtt.MQTTv311,
|
||||
)
|
||||
|
||||
@@ -2569,9 +2667,16 @@ class BambuMQTTClient:
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
self._client.tls_set_context(ssl_context)
|
||||
|
||||
# Use shorter keepalive (15s) for faster disconnect detection
|
||||
# Paho considers connection lost after 1.5x keepalive with no response
|
||||
self._client.connect_async(self.ip_address, self.MQTT_PORT, keepalive=15)
|
||||
# Backoff reconnects to avoid tight reconnect loops on unstable brokers.
|
||||
self._client.reconnect_delay_set(min_delay=1, max_delay=30)
|
||||
|
||||
# Keepalive: paho sends PINGREQs at this interval, broker considers
|
||||
# client dead at 1.5x. 30s is a good balance — fast enough to detect
|
||||
# real network loss (45s), not so aggressive that transient hiccups
|
||||
# trigger false disconnects. Stale detection (60s no messages) handles
|
||||
# the P1S/P1P firmware bug where the broker stops publishing but the
|
||||
# TCP connection stays alive.
|
||||
self._client.connect_async(self.ip_address, self.MQTT_PORT, keepalive=30)
|
||||
self._client.loop_start()
|
||||
|
||||
def start_print(
|
||||
@@ -2606,25 +2711,36 @@ class BambuMQTTClient:
|
||||
# Bambu print command format - matches Bambu Studio's format
|
||||
# Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
|
||||
ams_mapping2 = []
|
||||
# BambuStudio converts virtual tray IDs (254/255) to -1 in the flat
|
||||
# ams_mapping and relies on ams_mapping2 for external spool details.
|
||||
# Passing raw 254/255 in the flat array causes H2D firmware to fail
|
||||
# with 0700_8012 "Failed to get AMS mapping table".
|
||||
flat_ams_mapping = []
|
||||
if ams_mapping is not None:
|
||||
for tray_id in ams_mapping:
|
||||
# Ensure tray_id is an integer (may be string from JSON)
|
||||
tray_id = int(tray_id) if tray_id is not None else -1
|
||||
if tray_id == -1:
|
||||
# Unmapped filament slot
|
||||
flat_ams_mapping.append(-1)
|
||||
ams_mapping2.append({"ams_id": 255, "slot_id": 255})
|
||||
elif tray_id >= 254:
|
||||
# External spool: 254 = main nozzle, 255 = deputy nozzle
|
||||
# For ams_mapping2, slot_id is 0 (main) or 1 (deputy), not the tray_id
|
||||
external_slot = 0 if tray_id == 254 else 1
|
||||
ams_mapping2.append({"ams_id": 255, "slot_id": external_slot})
|
||||
# External/virtual spool: each virtual tray is its own AMS unit
|
||||
# with a single slot (slot 0). BambuStudio convention:
|
||||
# 255 = VIRTUAL_TRAY_MAIN_ID (main/left nozzle)
|
||||
# 254 = VIRTUAL_TRAY_DEPUTY_ID (deputy/right nozzle)
|
||||
# Flat mapping must use -1 (firmware doesn't accept raw 254/255).
|
||||
flat_ams_mapping.append(-1)
|
||||
ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
|
||||
elif tray_id >= 128:
|
||||
# AMS-HT: global tray ID IS the ams_id (single tray per unit)
|
||||
flat_ams_mapping.append(tray_id)
|
||||
ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
|
||||
else:
|
||||
# Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
|
||||
ams_id = tray_id // 4
|
||||
slot_id = tray_id % 4
|
||||
flat_ams_mapping.append(tray_id)
|
||||
ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
|
||||
|
||||
# H2D series requires integer values (0/1) for calibration/leveling fields
|
||||
@@ -2675,7 +2791,7 @@ class BambuMQTTClient:
|
||||
|
||||
# Add AMS mapping if provided
|
||||
if ams_mapping is not None:
|
||||
command["print"]["ams_mapping"] = ams_mapping
|
||||
command["print"]["ams_mapping"] = flat_ams_mapping
|
||||
command["print"]["ams_mapping2"] = ams_mapping2
|
||||
|
||||
logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
|
||||
|
||||
@@ -934,6 +934,47 @@ class NotificationService:
|
||||
providers, title, message, db, "print_progress", printer_id, printer_name, image_data=image_data
|
||||
)
|
||||
|
||||
async def on_print_missing_spool_assignment(
|
||||
self,
|
||||
printer_id: int,
|
||||
printer_name: str,
|
||||
missing_slots: list[dict[str, str]],
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Handle print-start event when required trays are missing spool assignments."""
|
||||
if not missing_slots:
|
||||
return
|
||||
|
||||
providers = await self._get_providers_for_event(db, "on_print_missing_spool_assignment", printer_id)
|
||||
if not providers:
|
||||
return
|
||||
|
||||
missing_slot_names = ", ".join(slot.get("slot", "Unknown") for slot in missing_slots)
|
||||
detail_lines = []
|
||||
for slot in missing_slots:
|
||||
slot_name = slot.get("slot", "Unknown")
|
||||
profile = slot.get("profile", "Unknown")
|
||||
detail_lines.append(f"- {slot_name}: {profile}")
|
||||
missing_profile_details = "\n".join(detail_lines)
|
||||
|
||||
variables = {
|
||||
"printer": printer_name,
|
||||
"missing_slots": missing_slot_names,
|
||||
"missing_slot_details": missing_profile_details,
|
||||
}
|
||||
|
||||
title, message = await self._build_message_from_template(db, "print_missing_spool_assignment", variables)
|
||||
await self._send_to_providers(
|
||||
providers,
|
||||
title,
|
||||
message,
|
||||
db,
|
||||
"print_missing_spool_assignment",
|
||||
printer_id,
|
||||
printer_name,
|
||||
force_immediate=True,
|
||||
)
|
||||
|
||||
async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
|
||||
"""Handle printer offline event."""
|
||||
providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
|
||||
|
||||
@@ -1571,6 +1571,8 @@ class PrintScheduler:
|
||||
elif base_name.endswith(".3mf"):
|
||||
base_name = base_name[:-4] # Remove .3mf
|
||||
remote_filename = f"{base_name}.3mf"
|
||||
# Sanitize: firmware parses ftp://{filename} as a URL, spaces break it
|
||||
remote_filename = remote_filename.replace(" ", "_")
|
||||
# Upload to root directory (not /cache/) - the start_print command references
|
||||
# files by name only (ftp://{filename}), so they must be in the root
|
||||
remote_path = f"/{remote_filename}"
|
||||
|
||||
@@ -55,6 +55,19 @@ A1_MODELS = frozenset(
|
||||
]
|
||||
)
|
||||
|
||||
# Models affected by the stg_cur=0 idle bug (firmware reports stg_cur=0 when idle,
|
||||
# which maps to "Printing" in STAGE_NAMES and overrides the correct IDLE state)
|
||||
STG_CUR_IDLE_BUG_MODELS = A1_MODELS | frozenset(
|
||||
[
|
||||
# Display names
|
||||
"P1P",
|
||||
"P1S",
|
||||
# Internal codes (from MQTT/SSDP)
|
||||
"C11", # P1P
|
||||
"C12", # P1S
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def supports_chamber_temp(model: str | None) -> bool:
|
||||
"""Check if a printer model has a real chamber temperature sensor.
|
||||
@@ -72,14 +85,14 @@ def supports_chamber_temp(model: str | None) -> bool:
|
||||
def has_stg_cur_idle_bug(model: str | None) -> bool:
|
||||
"""Check if a printer model may incorrectly report stg_cur=0 when idle.
|
||||
|
||||
Some A1/A1 Mini firmware versions report stg_cur=0 (which maps to "Printing")
|
||||
even when the printer is idle. This is a known firmware bug that was observed
|
||||
in the Home Assistant Bambu Lab integration.
|
||||
Some firmware versions report stg_cur=0 (which maps to "Printing")
|
||||
even when the printer is idle. Originally observed on A1/A1 Mini via the
|
||||
Home Assistant Bambu Lab integration, also confirmed on P1S.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
model_upper = model.strip().upper()
|
||||
return model_upper in A1_MODELS
|
||||
return model_upper in STG_CUR_IDLE_BUG_MODELS
|
||||
|
||||
|
||||
# Minimum firmware versions for AMS drying support (confirmed via capture testing)
|
||||
@@ -612,6 +625,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
|
||||
"nozzle_temp_max": tray.get("nozzle_temp_max"),
|
||||
"drying_temp": tray.get("drying_temp"),
|
||||
"drying_time": tray.get("drying_time"),
|
||||
"state": tray.get("state"),
|
||||
}
|
||||
)
|
||||
# Prefer humidity_raw (actual percentage) over humidity (index 1-5)
|
||||
|
||||
@@ -416,7 +416,7 @@ class SmartPlugManager:
|
||||
logger.warning("Failed to update plug %s pending state: %s", plug_id, e)
|
||||
|
||||
async def _mark_auto_off_executed(self, plug_id: int):
|
||||
"""Disable auto-off after it was executed (one-shot behavior)."""
|
||||
"""Disable auto-off after it was executed (one-shot behavior unless persistent)."""
|
||||
try:
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
@@ -425,14 +425,18 @@ class SmartPlugManager:
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
|
||||
plug = result.scalar_one_or_none()
|
||||
if plug:
|
||||
plug.auto_off = False # Disable auto-off (one-shot behavior)
|
||||
if not plug.auto_off_persistent:
|
||||
plug.auto_off = False # Disable auto-off (one-shot behavior)
|
||||
plug.auto_off_executed = False # Reset the flag
|
||||
plug.auto_off_pending = False # Clear pending state
|
||||
plug.auto_off_pending_since = None
|
||||
plug.last_state = "OFF"
|
||||
plug.last_checked = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
logger.info("Auto-off executed and disabled for plug %s", plug_id)
|
||||
if plug.auto_off_persistent:
|
||||
logger.info("Auto-off executed for plug %s (persistent, stays enabled)", plug_id)
|
||||
else:
|
||||
logger.info("Auto-off executed and disabled for plug %s", plug_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to update plug %s after auto-off: %s", plug_id, e)
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import logging
|
||||
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
from backend.app.services.bambu_mqtt import PrinterState
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
|
||||
def _global_tray_from_assignment(ams_id: int, tray_id: int) -> int:
|
||||
"""Convert an assignment tuple to Bambuddy global tray ID."""
|
||||
if ams_id in (254, 255):
|
||||
return 254 + tray_id
|
||||
if ams_id >= 128:
|
||||
return ams_id
|
||||
return ams_id * 4 + tray_id
|
||||
|
||||
|
||||
def _slot_label_from_global_tray(global_tray_id: int) -> str:
|
||||
"""Return a human-readable slot label from a global tray ID."""
|
||||
if global_tray_id == 254:
|
||||
return "Ext-L"
|
||||
if global_tray_id == 255:
|
||||
return "Ext-R"
|
||||
if global_tray_id >= 128:
|
||||
return f"HT-{chr(65 + (global_tray_id - 128))}"
|
||||
ams_id = global_tray_id // 4
|
||||
tray_id = global_tray_id % 4
|
||||
return f"{chr(65 + ams_id)}{tray_id + 1}"
|
||||
|
||||
|
||||
def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tray_id: int) -> tuple[str, str]:
|
||||
"""Resolve expected tray material/profile and color for a global tray ID from current printer state."""
|
||||
if not state or not state.raw_data:
|
||||
return ("Unknown", "Unknown")
|
||||
|
||||
ams_raw = state.raw_data.get("ams", {})
|
||||
ams_units = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
|
||||
|
||||
vt_trays = state.raw_data.get("vt_tray", [])
|
||||
if not isinstance(vt_trays, list):
|
||||
vt_trays = []
|
||||
|
||||
for tray in vt_trays:
|
||||
if not isinstance(tray, dict):
|
||||
continue
|
||||
if int(tray.get("id", -1)) == global_tray_id:
|
||||
profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
|
||||
color = tray.get("tray_color") or "Unknown"
|
||||
return (profile, color)
|
||||
|
||||
for ams in ams_units:
|
||||
if not isinstance(ams, dict):
|
||||
continue
|
||||
ams_id = int(ams.get("id", -1))
|
||||
trays = ams.get("tray", [])
|
||||
if not isinstance(trays, list):
|
||||
continue
|
||||
for tray in trays:
|
||||
if not isinstance(tray, dict):
|
||||
continue
|
||||
tray_id = int(tray.get("id", -1))
|
||||
candidate = ams_id if ams_id >= 128 else (ams_id * 4 + tray_id)
|
||||
if candidate == global_tray_id:
|
||||
profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
|
||||
color = tray.get("tray_color") or "Unknown"
|
||||
return (profile, color)
|
||||
|
||||
return ("Unknown", "Unknown")
|
||||
|
||||
|
||||
def _decode_mqtt_mapping_to_global_trays(mapping_raw: object) -> list[int]:
|
||||
"""Decode printer MQTT mapping values into Bambuddy global tray IDs."""
|
||||
if not isinstance(mapping_raw, list) or not mapping_raw:
|
||||
return []
|
||||
|
||||
decoded: list[int] = []
|
||||
for value in mapping_raw:
|
||||
try:
|
||||
if isinstance(value, int):
|
||||
encoded = value
|
||||
elif isinstance(value, str):
|
||||
encoded = int(value, 10)
|
||||
else:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if encoded >= 65535:
|
||||
continue
|
||||
|
||||
ams_hw_id = (encoded >> 8) & 0xFF
|
||||
slot = encoded & 0xFF
|
||||
|
||||
if 0 <= ams_hw_id <= 3:
|
||||
decoded.append(ams_hw_id * 4 + (slot & 0x03))
|
||||
elif 128 <= ams_hw_id <= 135:
|
||||
decoded.append(ams_hw_id)
|
||||
elif ams_hw_id in (254, 255):
|
||||
decoded.append(255 if slot == 255 else 254)
|
||||
|
||||
return decoded
|
||||
|
||||
|
||||
async def notify_missing_spool_assignments_on_print_start(
|
||||
printer_id: int,
|
||||
data: dict,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
"""Send notification when print-start mapping references unassigned trays."""
|
||||
explicit_mapping = data.get("ams_mapping")
|
||||
explicit_values = (
|
||||
[value for value in explicit_mapping if isinstance(value, int)]
|
||||
if isinstance(explicit_mapping, list)
|
||||
else []
|
||||
)
|
||||
raw_mapping = data.get("raw_data", {}).get("mapping") if isinstance(data.get("raw_data"), dict) else None
|
||||
decoded_values = _decode_mqtt_mapping_to_global_trays(raw_mapping)
|
||||
mapping_values = explicit_values if explicit_values else decoded_values
|
||||
|
||||
used_global_trays = {value for value in mapping_values if value >= 0}
|
||||
if not used_global_trays:
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
printer = await db.get(Printer, printer_id)
|
||||
printer_name = printer.name if printer else f"Printer {printer_id}"
|
||||
|
||||
assignments_result = await db.execute(
|
||||
SpoolAssignment.__table__.select().where(SpoolAssignment.printer_id == printer_id)
|
||||
)
|
||||
assignments = assignments_result.fetchall()
|
||||
assigned_global_trays = {
|
||||
_global_tray_from_assignment(assignment.ams_id, assignment.tray_id) for assignment in assignments
|
||||
}
|
||||
|
||||
missing_global = sorted(used_global_trays - assigned_global_trays)
|
||||
if not missing_global:
|
||||
return
|
||||
|
||||
state = printer_manager.get_status(printer_id)
|
||||
missing_slots = []
|
||||
for global_id in missing_global:
|
||||
profile, color = _tray_profile_and_color_for_global_id(state, global_id)
|
||||
missing_slots.append(
|
||||
{
|
||||
"slot": _slot_label_from_global_tray(global_id),
|
||||
"profile": profile,
|
||||
"color": color,
|
||||
}
|
||||
)
|
||||
|
||||
await ws_manager.send_missing_spool_assignment(
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
missing_slots=missing_slots,
|
||||
)
|
||||
|
||||
await notification_service.on_print_missing_spool_assignment(
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
missing_slots=missing_slots,
|
||||
db=db,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Missing spool-assignment notification failed: %s", e)
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.models.spool import Spool
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
from backend.app.utils.tag_normalization import (
|
||||
normalize_tag_uid as _normalize_tag_uid,
|
||||
normalize_tray_uuid as _normalize_tray_uuid,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,14 +22,17 @@ ZERO_TRAY_UUID = "00000000000000000000000000000000"
|
||||
|
||||
def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool:
|
||||
"""Check if a tag/UUID pair contains a non-zero, non-empty value."""
|
||||
uid_valid = bool(tag_uid) and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid)
|
||||
uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid)
|
||||
uid = _normalize_tag_uid(tag_uid)
|
||||
uuid = _normalize_tray_uuid(tray_uuid)
|
||||
uid_valid = bool(uid) and uid != ZERO_TAG_UID and uid != "0" * len(uid)
|
||||
uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != "0" * len(uuid)
|
||||
return uid_valid or uuid_valid
|
||||
|
||||
|
||||
def is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool:
|
||||
"""Check if an AMS tray contains a Bambu Lab RFID spool (has valid UUID or slicer preset)."""
|
||||
uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid)
|
||||
uuid = _normalize_tray_uuid(tray_uuid)
|
||||
uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != "0" * len(uuid)
|
||||
has_preset = bool(tray_info_idx)
|
||||
return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset)
|
||||
|
||||
@@ -43,8 +50,8 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
|
||||
tray_sub_brands = tray_data.get("tray_sub_brands", "") # "PLA Basic"
|
||||
tray_color = tray_data.get("tray_color", "FFFFFFFF") # RRGGBBAA
|
||||
tray_id_name = tray_data.get("tray_id_name", "") # Color name e.g. "Jade White"
|
||||
tag_uid = tray_data.get("tag_uid", "")
|
||||
tray_uuid = tray_data.get("tray_uuid", "")
|
||||
tag_uid = _normalize_tag_uid(tray_data.get("tag_uid", ""))
|
||||
tray_uuid = _normalize_tray_uuid(tray_data.get("tray_uuid", ""))
|
||||
tray_info_idx = tray_data.get("tray_info_idx", "")
|
||||
nozzle_min = tray_data.get("nozzle_temp_min", 0)
|
||||
nozzle_max = tray_data.get("nozzle_temp_max", 0)
|
||||
@@ -165,17 +172,118 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
|
||||
return spool
|
||||
|
||||
|
||||
async def find_matching_untagged_spool(db: AsyncSession, tray_data: dict) -> Spool | None:
|
||||
"""Find an existing untagged inventory spool matching brand/material/color.
|
||||
|
||||
When a Bambu Lab spool is detected in the AMS but no tag match exists,
|
||||
check if the user has a manually-added spool with the same properties
|
||||
that hasn't been linked to a tag yet. Returns the oldest match (FIFO).
|
||||
"""
|
||||
tray_type = tray_data.get("tray_type", "")
|
||||
tray_sub_brands = tray_data.get("tray_sub_brands", "")
|
||||
tray_color = tray_data.get("tray_color", "") # RRGGBBAA
|
||||
|
||||
if not tray_type or not tray_color:
|
||||
return None
|
||||
|
||||
# Parse material the same way create_spool_from_tray does
|
||||
material = tray_type
|
||||
subtype = None
|
||||
if tray_sub_brands and " " in tray_sub_brands:
|
||||
parts = tray_sub_brands.split(" ", 1)
|
||||
if parts[0].upper() == material.upper():
|
||||
subtype = parts[1]
|
||||
else:
|
||||
material = tray_sub_brands
|
||||
elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
|
||||
material = tray_sub_brands
|
||||
|
||||
# Build query: active spools with no tag, matching brand + material + color
|
||||
query = (
|
||||
select(Spool)
|
||||
.options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
|
||||
.where(
|
||||
Spool.archived_at.is_(None),
|
||||
Spool.tag_uid.is_(None),
|
||||
Spool.tray_uuid.is_(None),
|
||||
func.upper(Spool.material) == material.upper(),
|
||||
func.upper(Spool.rgba) == tray_color.upper(),
|
||||
)
|
||||
)
|
||||
|
||||
# Match subtype if parsed (e.g. "Basic", "Matte")
|
||||
if subtype:
|
||||
query = query.where(func.upper(Spool.subtype) == subtype.upper())
|
||||
else:
|
||||
query = query.where(Spool.subtype.is_(None))
|
||||
|
||||
# FIFO: oldest spool first (user likely added in purchase order)
|
||||
query = query.order_by(Spool.created_at.asc()).limit(1)
|
||||
|
||||
result = await db.execute(query)
|
||||
spool = result.scalar_one_or_none()
|
||||
|
||||
if spool:
|
||||
logger.info(
|
||||
"Found matching untagged spool %d: %s %s %s (rgba=%s)",
|
||||
spool.id,
|
||||
spool.brand or "",
|
||||
spool.material,
|
||||
spool.color_name or "",
|
||||
spool.rgba or "",
|
||||
)
|
||||
|
||||
return spool
|
||||
|
||||
|
||||
async def link_tag_to_inventory_spool(db: AsyncSession, spool: Spool, tray_data: dict) -> None:
|
||||
"""Link RFID tag data from AMS tray to an existing inventory spool."""
|
||||
tag_uid = tray_data.get("tag_uid", "")
|
||||
tray_uuid = tray_data.get("tray_uuid", "")
|
||||
tray_info_idx = tray_data.get("tray_info_idx", "")
|
||||
|
||||
if tag_uid and tag_uid != ZERO_TAG_UID:
|
||||
spool.tag_uid = tag_uid
|
||||
if tray_uuid and tray_uuid != ZERO_TRAY_UUID:
|
||||
spool.tray_uuid = tray_uuid
|
||||
spool.data_origin = "rfid_linked"
|
||||
spool.tag_type = "bambulab"
|
||||
|
||||
# Update slicer preset if not already set
|
||||
if tray_info_idx and not spool.slicer_filament:
|
||||
spool.slicer_filament = tray_info_idx
|
||||
try:
|
||||
from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
|
||||
|
||||
name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)
|
||||
if name and not spool.slicer_filament_name:
|
||||
spool.slicer_filament_name = name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await db.flush()
|
||||
logger.info(
|
||||
"Linked RFID tag to existing spool %d (tag=%s uuid=%s origin=rfid_linked)",
|
||||
spool.id,
|
||||
spool.tag_uid or "",
|
||||
spool.tray_uuid or "",
|
||||
)
|
||||
|
||||
|
||||
async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None:
|
||||
"""Look up an active spool by RFID tag UID or Bambu Lab tray UUID.
|
||||
|
||||
Prefers tray_uuid match over tag_uid (more reliable).
|
||||
"""
|
||||
tray_uuid_norm = _normalize_tray_uuid(tray_uuid)
|
||||
tag_uid_norm = _normalize_tag_uid(tag_uid)
|
||||
|
||||
# Try tray_uuid first (Bambu Lab spools — more reliable)
|
||||
if tray_uuid and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid):
|
||||
if tray_uuid_norm and tray_uuid_norm != ZERO_TRAY_UUID and tray_uuid_norm != "0" * len(tray_uuid_norm):
|
||||
result = await db.execute(
|
||||
select(Spool)
|
||||
.options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
|
||||
.where(Spool.tray_uuid == tray_uuid, Spool.archived_at.is_(None))
|
||||
.where(func.upper(Spool.tray_uuid) == tray_uuid_norm, Spool.archived_at.is_(None))
|
||||
.limit(1)
|
||||
)
|
||||
spool = result.scalar_one_or_none()
|
||||
@@ -183,17 +291,77 @@ async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Sp
|
||||
return spool
|
||||
|
||||
# Fall back to tag_uid
|
||||
if tag_uid and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid):
|
||||
if tag_uid_norm and tag_uid_norm != ZERO_TAG_UID and tag_uid_norm != "0" * len(tag_uid_norm):
|
||||
result = await db.execute(
|
||||
select(Spool)
|
||||
.options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
|
||||
.where(Spool.tag_uid == tag_uid, Spool.archived_at.is_(None))
|
||||
.where(func.upper(Spool.tag_uid) == tag_uid_norm, Spool.archived_at.is_(None))
|
||||
.limit(1)
|
||||
)
|
||||
spool = result.scalar_one_or_none()
|
||||
if spool:
|
||||
return spool
|
||||
|
||||
# Compatibility fallback: some readers report 4-byte UID (8 hex) while
|
||||
# stored values may contain longer forms. Prefer suffix match only.
|
||||
if len(tag_uid_norm) >= 8:
|
||||
suffix8 = tag_uid_norm[-8:]
|
||||
short_uid_body = tag_uid_norm[1:] if len(tag_uid_norm) == 8 else ""
|
||||
|
||||
# Build LIKE patterns for candidates search
|
||||
like_patterns = [
|
||||
func.upper(Spool.tag_uid).like(f"%{tag_uid_norm}"),
|
||||
func.upper(Spool.tag_uid).like(f"%{suffix8}"),
|
||||
]
|
||||
if short_uid_body:
|
||||
like_patterns.append(func.upper(Spool.tag_uid).like(f"%{short_uid_body}%"))
|
||||
|
||||
candidates = await db.execute(
|
||||
select(Spool)
|
||||
.options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
|
||||
.where(
|
||||
Spool.tag_uid.is_not(None),
|
||||
Spool.archived_at.is_(None),
|
||||
or_(*like_patterns),
|
||||
)
|
||||
.limit(100)
|
||||
)
|
||||
for candidate in candidates.scalars().all():
|
||||
candidate_uid = _normalize_tag_uid(candidate.tag_uid)
|
||||
if not candidate_uid:
|
||||
continue
|
||||
if candidate_uid == tag_uid_norm:
|
||||
return candidate
|
||||
if len(candidate_uid) > len(tag_uid_norm) and candidate_uid.endswith(tag_uid_norm):
|
||||
return candidate
|
||||
if len(tag_uid_norm) > len(candidate_uid) and tag_uid_norm.endswith(candidate_uid):
|
||||
return candidate
|
||||
# Backward-compatible matching: allow first-character mismatch
|
||||
# when remaining characters match. This handles cases where the same
|
||||
# physical tag reports different first bytes across different readers
|
||||
# (e.g., one reader reports "A45012F", another reports "B45012F").
|
||||
if len(tag_uid_norm) == len(candidate_uid) and len(tag_uid_norm) > 1:
|
||||
# Same length: check if all chars except the first match
|
||||
if candidate_uid[1:] == tag_uid_norm[1:]:
|
||||
logger.warning(
|
||||
"Matched spool %d via first-char variance: stored=%s → scanned=%s",
|
||||
candidate.id,
|
||||
candidate_uid,
|
||||
tag_uid_norm,
|
||||
)
|
||||
return candidate
|
||||
# Short UID (8 chars) matching: allow first-character mismatch
|
||||
# within the first 8 bytes when remaining 7 chars match.
|
||||
if len(tag_uid_norm) == 8 and len(candidate_uid) >= 8:
|
||||
if candidate_uid[:8][1:] == tag_uid_norm[1:]:
|
||||
logger.warning(
|
||||
"Matched spool %d via short UID variance: stored=%s → scanned=%s",
|
||||
candidate.id,
|
||||
candidate_uid,
|
||||
tag_uid_norm,
|
||||
)
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""SSH-based update service for SpoolBuddy devices.
|
||||
|
||||
Instead of the daemon updating itself (fragile: permission issues, self-modifying
|
||||
code, hardcoded branch), Bambuddy SSHes into the SpoolBuddy Pi and drives the
|
||||
update remotely: git fetch/checkout, pip install, systemctl restart.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SSH_USER = "spoolbuddy"
|
||||
DEFAULT_INSTALL_PATH = "/opt/bambuddy"
|
||||
|
||||
|
||||
def _get_ssh_key_dir() -> Path:
|
||||
"""Return (and create if needed) the directory for SpoolBuddy SSH keys."""
|
||||
key_dir = settings.base_dir / "spoolbuddy" / "ssh"
|
||||
if not key_dir.exists():
|
||||
key_dir.mkdir(mode=0o700, parents=True)
|
||||
return key_dir
|
||||
|
||||
|
||||
async def get_or_create_keypair() -> tuple[Path, Path]:
|
||||
"""Return (private_key_path, public_key_path), generating if missing."""
|
||||
key_dir = _get_ssh_key_dir()
|
||||
private_key = key_dir / "id_ed25519"
|
||||
public_key = key_dir / "id_ed25519.pub"
|
||||
|
||||
if private_key.exists() and public_key.exists():
|
||||
return private_key, public_key
|
||||
|
||||
logger.info("Generating SSH keypair for SpoolBuddy updates")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ssh-keygen",
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-f",
|
||||
str(private_key),
|
||||
"-N",
|
||||
"", # no passphrase
|
||||
"-C",
|
||||
"bambuddy-spoolbuddy",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"ssh-keygen failed: {stderr.decode()[:200]}")
|
||||
|
||||
private_key.chmod(0o600)
|
||||
logger.info("SSH keypair generated at %s", key_dir)
|
||||
return private_key, public_key
|
||||
|
||||
|
||||
async def get_public_key() -> str:
|
||||
"""Return the SSH public key content for pairing."""
|
||||
_, public_key = await get_or_create_keypair()
|
||||
return public_key.read_text().strip()
|
||||
|
||||
|
||||
def detect_current_branch() -> str:
|
||||
"""Detect the git branch Bambuddy is running on.
|
||||
|
||||
For native installs, reads from the .git directory.
|
||||
For Docker (no .git), falls back to GIT_BRANCH env var, then "main".
|
||||
"""
|
||||
git_dir = settings.base_dir / ".git"
|
||||
if git_dir.exists():
|
||||
git_path = shutil.which("git") or "/usr/bin/git"
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=str(settings.base_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return os.environ.get("GIT_BRANCH", "main")
|
||||
|
||||
|
||||
async def _run_ssh_command(
|
||||
ip: str,
|
||||
command: str,
|
||||
private_key: Path,
|
||||
timeout: int = 60,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Execute a command on a SpoolBuddy device via SSH.
|
||||
|
||||
Returns (returncode, stdout, stderr).
|
||||
"""
|
||||
ssh_path = shutil.which("ssh") or "/usr/bin/ssh"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ssh_path,
|
||||
"-i",
|
||||
str(private_key),
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"LogLevel=ERROR",
|
||||
f"{SSH_USER}@{ip}",
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except TimeoutError:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
return -1, "", "SSH command timed out"
|
||||
|
||||
return proc.returncode, stdout.decode(), stderr.decode()
|
||||
|
||||
|
||||
async def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None:
|
||||
"""SSH into a SpoolBuddy device and update it to match Bambuddy's branch.
|
||||
|
||||
Updates device.update_status/update_message in the DB and broadcasts
|
||||
progress via WebSocket at each step.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.api.routes.spoolbuddy import ws_manager
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
|
||||
|
||||
install_path = install_path or DEFAULT_INSTALL_PATH
|
||||
branch = detect_current_branch()
|
||||
|
||||
async def _update_progress(status: str, message: str) -> None:
|
||||
"""Update device status in DB and broadcast via WebSocket."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
|
||||
device = result.scalar_one_or_none()
|
||||
if device:
|
||||
device.update_status = status
|
||||
device.update_message = message[:255] if message else None
|
||||
if status in ("complete", "error"):
|
||||
device.pending_command = None
|
||||
await db.commit()
|
||||
|
||||
await ws_manager.broadcast(
|
||||
{
|
||||
"type": "spoolbuddy_update",
|
||||
"device_id": device_id,
|
||||
"update_status": status,
|
||||
"update_message": message[:255] if message else None,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
private_key, _ = await get_or_create_keypair()
|
||||
|
||||
# Step 1: Test SSH connectivity
|
||||
await _update_progress("updating", "Connecting via SSH...")
|
||||
rc, _, stderr = await _run_ssh_command(ip_address, "echo ok", private_key)
|
||||
if rc != 0:
|
||||
await _update_progress("error", f"SSH connection failed: {stderr[:200]}")
|
||||
return
|
||||
|
||||
# Step 2: Git fetch
|
||||
await _update_progress("updating", f"Fetching latest code (branch: {branch})...")
|
||||
rc, _, stderr = await _run_ssh_command(
|
||||
ip_address,
|
||||
f"cd {install_path} && git -c safe.directory={install_path} fetch origin {branch}",
|
||||
private_key,
|
||||
timeout=120,
|
||||
)
|
||||
if rc != 0:
|
||||
await _update_progress("error", f"git fetch failed: {stderr[:200]}")
|
||||
return
|
||||
|
||||
# Step 3: Git checkout + reset
|
||||
await _update_progress("updating", "Applying update...")
|
||||
rc, _, stderr = await _run_ssh_command(
|
||||
ip_address,
|
||||
f"cd {install_path} && git -c safe.directory={install_path} checkout {branch} "
|
||||
f"&& git -c safe.directory={install_path} reset --hard origin/{branch}",
|
||||
private_key,
|
||||
)
|
||||
if rc != 0:
|
||||
await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}")
|
||||
return
|
||||
|
||||
# Step 4: Install dependencies
|
||||
await _update_progress("updating", "Installing dependencies...")
|
||||
venv_pip = f"{install_path}/spoolbuddy/venv/bin/pip"
|
||||
rc, _, stderr = await _run_ssh_command(
|
||||
ip_address,
|
||||
f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
|
||||
private_key,
|
||||
timeout=120,
|
||||
)
|
||||
if rc != 0:
|
||||
logger.warning("SpoolBuddy %s: pip install returned non-zero (continuing): %s", device_id, stderr[:200])
|
||||
|
||||
# Step 5: Restart daemon
|
||||
await _update_progress("updating", "Restarting daemon...")
|
||||
rc, _, stderr = await _run_ssh_command(
|
||||
ip_address,
|
||||
"sudo /usr/bin/systemctl restart spoolbuddy.service",
|
||||
private_key,
|
||||
)
|
||||
if rc != 0:
|
||||
await _update_progress("error", f"Service restart failed: {stderr[:200]}")
|
||||
return
|
||||
|
||||
# Step 6: Clear browser cache and restart kiosk
|
||||
# Remove Chromium's Service Worker + cache storage to prevent stale frontend
|
||||
await _run_ssh_command(
|
||||
ip_address,
|
||||
"sudo find /home -maxdepth 5 -path '*/chromium/Default/Service Worker' -type d -exec rm -rf {} + 2>/dev/null; true",
|
||||
private_key,
|
||||
)
|
||||
rc, _, stderr = await _run_ssh_command(
|
||||
ip_address,
|
||||
"sudo /usr/bin/systemctl restart getty@tty1.service",
|
||||
private_key,
|
||||
)
|
||||
if rc != 0:
|
||||
logger.warning("SpoolBuddy %s: kiosk restart failed (non-fatal): %s", device_id, stderr[:200])
|
||||
|
||||
logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, branch)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("SpoolBuddy %s: SSH update failed: %s", device_id, e)
|
||||
await _update_progress("error", f"Update failed: {str(e)[:200]}")
|
||||
@@ -164,6 +164,70 @@ class PrintSession:
|
||||
_active_sessions: dict[int, PrintSession] = {}
|
||||
|
||||
|
||||
def _to_epoch_seconds(value: datetime | None) -> float | None:
|
||||
"""Convert datetime to epoch seconds, assuming UTC for naive values."""
|
||||
if value is None:
|
||||
return None
|
||||
dt = value
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.timestamp()
|
||||
|
||||
|
||||
async def _resolve_spool_id_for_tray(
|
||||
printer_id: int,
|
||||
ams_id: int,
|
||||
tray_id: int,
|
||||
db: AsyncSession,
|
||||
spool_assignments_snapshot: dict[tuple[int, int], int] | None = None,
|
||||
print_started_at: datetime | None = None,
|
||||
) -> int | None:
|
||||
"""Resolve spool ID for a tray with safe support for mid-print reassignment.
|
||||
|
||||
Resolution order:
|
||||
1. If snapshot exists and live assignment changed *during this print*, use live spool.
|
||||
2. Otherwise use snapshot spool when available.
|
||||
3. Fall back to live assignment.
|
||||
"""
|
||||
key = (ams_id, tray_id)
|
||||
snapshot_spool_id = spool_assignments_snapshot.get(key) if spool_assignments_snapshot else None
|
||||
|
||||
# Backward-compatible fast path: if we have a snapshot but no print-start
|
||||
# timestamp, preserve legacy behavior and avoid extra DB lookups.
|
||||
if snapshot_spool_id is not None and print_started_at is None:
|
||||
return snapshot_spool_id
|
||||
|
||||
result = await db.execute(
|
||||
select(SpoolAssignment).where(
|
||||
SpoolAssignment.printer_id == printer_id,
|
||||
SpoolAssignment.ams_id == ams_id,
|
||||
SpoolAssignment.tray_id == tray_id,
|
||||
)
|
||||
)
|
||||
live_assignment = result.scalar_one_or_none()
|
||||
|
||||
if snapshot_spool_id is not None:
|
||||
if live_assignment and live_assignment.spool_id != snapshot_spool_id:
|
||||
live_created_ts = _to_epoch_seconds(getattr(live_assignment, "created_at", None))
|
||||
started_ts = _to_epoch_seconds(print_started_at)
|
||||
if live_created_ts is not None and started_ts is not None and live_created_ts >= started_ts:
|
||||
logger.info(
|
||||
"[UsageTracker] Assignment changed during print for printer %d AMS%d-T%d: snapshot spool %d -> live spool %d",
|
||||
printer_id,
|
||||
ams_id,
|
||||
tray_id,
|
||||
snapshot_spool_id,
|
||||
live_assignment.spool_id,
|
||||
)
|
||||
return live_assignment.spool_id
|
||||
return snapshot_spool_id
|
||||
|
||||
if live_assignment:
|
||||
return live_assignment.spool_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def on_print_start(printer_id: int, data: dict, printer_manager, db: AsyncSession | None = None) -> None:
|
||||
"""Capture AMS tray remain% and spool assignments at print start."""
|
||||
state = printer_manager.get_status(printer_id)
|
||||
@@ -323,6 +387,7 @@ async def on_print_complete(
|
||||
last_layer_num=data.get("last_layer_num", 0),
|
||||
default_filament_cost=default_filament_cost,
|
||||
spool_assignments=session.spool_assignments if session else None,
|
||||
print_started_at=session.started_at if session else None,
|
||||
)
|
||||
results.extend(threemf_results)
|
||||
|
||||
@@ -357,20 +422,16 @@ async def on_print_complete(
|
||||
if delta_pct <= 0:
|
||||
continue # No consumption or tray was refilled
|
||||
|
||||
# Look up spool: prefer snapshot (survives mid-print unlink), fall back to live query
|
||||
spool_id = session.spool_assignments.get(key) if session.spool_assignments else None
|
||||
spool_id = await _resolve_spool_id_for_tray(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
db=db,
|
||||
spool_assignments_snapshot=session.spool_assignments,
|
||||
print_started_at=session.started_at,
|
||||
)
|
||||
if spool_id is None:
|
||||
result = await db.execute(
|
||||
select(SpoolAssignment).where(
|
||||
SpoolAssignment.printer_id == printer_id,
|
||||
SpoolAssignment.ams_id == ams_id,
|
||||
SpoolAssignment.tray_id == tray_id,
|
||||
)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
if not assignment:
|
||||
continue
|
||||
spool_id = assignment.spool_id
|
||||
continue
|
||||
|
||||
# Load spool
|
||||
spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
|
||||
@@ -463,6 +524,7 @@ async def _track_from_3mf(
|
||||
last_layer_num: int = 0,
|
||||
default_filament_cost: float = 0.0,
|
||||
spool_assignments: dict[tuple[int, int], int] | None = None,
|
||||
print_started_at: datetime | None = None,
|
||||
) -> list[dict]:
|
||||
"""Track usage from 3MF per-filament slicer data (primary path).
|
||||
|
||||
@@ -726,26 +788,22 @@ async def _track_from_3mf(
|
||||
segment_grams,
|
||||
)
|
||||
|
||||
# Find spool for this tray
|
||||
seg_spool_id = spool_assignments.get(seg_key) if spool_assignments else None
|
||||
seg_spool_id = await _resolve_spool_id_for_tray(
|
||||
printer_id=printer_id,
|
||||
ams_id=seg_ams_id,
|
||||
tray_id=seg_tray_id,
|
||||
db=db,
|
||||
spool_assignments_snapshot=spool_assignments,
|
||||
print_started_at=print_started_at,
|
||||
)
|
||||
if seg_spool_id is None:
|
||||
assign_result = await db.execute(
|
||||
select(SpoolAssignment).where(
|
||||
SpoolAssignment.printer_id == printer_id,
|
||||
SpoolAssignment.ams_id == seg_ams_id,
|
||||
SpoolAssignment.tray_id == seg_tray_id,
|
||||
)
|
||||
logger.info(
|
||||
"[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
|
||||
printer_id,
|
||||
seg_ams_id,
|
||||
seg_tray_id,
|
||||
)
|
||||
assignment = assign_result.scalar_one_or_none()
|
||||
if not assignment:
|
||||
logger.info(
|
||||
"[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
|
||||
printer_id,
|
||||
seg_ams_id,
|
||||
seg_tray_id,
|
||||
)
|
||||
continue
|
||||
seg_spool_id = assignment.spool_id
|
||||
continue
|
||||
|
||||
spool_result = await db.execute(select(Spool).where(Spool.id == seg_spool_id))
|
||||
spool = spool_result.scalar_one_or_none()
|
||||
@@ -851,23 +909,17 @@ async def _track_from_3mf(
|
||||
if key in handled_trays:
|
||||
continue
|
||||
|
||||
# Find spool: prefer snapshot (survives mid-print unlink), fall back to live query
|
||||
spool_id = spool_assignments.get(key) if spool_assignments else None
|
||||
spool_id = await _resolve_spool_id_for_tray(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
db=db,
|
||||
spool_assignments_snapshot=spool_assignments,
|
||||
print_started_at=print_started_at,
|
||||
)
|
||||
if spool_id is None:
|
||||
assign_result = await db.execute(
|
||||
select(SpoolAssignment).where(
|
||||
SpoolAssignment.printer_id == printer_id,
|
||||
SpoolAssignment.ams_id == ams_id,
|
||||
SpoolAssignment.tray_id == tray_id,
|
||||
)
|
||||
)
|
||||
assignment = assign_result.scalar_one_or_none()
|
||||
if not assignment:
|
||||
logger.info(
|
||||
"[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id
|
||||
)
|
||||
continue
|
||||
spool_id = assignment.spool_id
|
||||
logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
|
||||
continue
|
||||
|
||||
# Load spool
|
||||
spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
|
||||
|
||||
@@ -1421,6 +1421,9 @@ class SlicerProxyManager:
|
||||
PRINTER_MQTT_PORT = 8883
|
||||
PRINTER_FILE_TRANSFER_PORT = 6000
|
||||
PRINTER_RTSP_PORT = 322 # X1/H2/P2 series camera (A1/P1 use port 6000)
|
||||
# Undocumented proprietary ports used by some models (A1, P1S, etc.)
|
||||
# BambuStudio requires port 2024 for printing; OrcaSlicer also needs 2025.
|
||||
PRINTER_AUX_PORTS = [2024, 2025, 2026]
|
||||
PRINTER_BIND_PORTS = [3000, 3002]
|
||||
|
||||
# Local listen ports - must match what Bambu Studio expects
|
||||
@@ -1461,6 +1464,7 @@ class SlicerProxyManager:
|
||||
self._mqtt_proxy: TLSProxy | None = None
|
||||
self._file_transfer_proxy: TCPProxy | None = None
|
||||
self._rtsp_proxy: TCPProxy | None = None
|
||||
self._aux_proxies: list[TCPProxy] = []
|
||||
self._bind_proxies: list[TCPProxy] = []
|
||||
self._bind_server = None
|
||||
self._probe_servers: list[asyncio.Server] = []
|
||||
@@ -1560,6 +1564,22 @@ class SlicerProxyManager:
|
||||
bind_address=self.bind_address,
|
||||
)
|
||||
|
||||
# Auxiliary ports (2024-2026) — raw TCP pass-through for undocumented
|
||||
# proprietary services. Required by BambuStudio/OrcaSlicer for some
|
||||
# models (A1, P1S). Silently ignored if the printer doesn't listen.
|
||||
for aux_port in self.PRINTER_AUX_PORTS:
|
||||
self._aux_proxies.append(
|
||||
TCPProxy(
|
||||
name=f"Aux-{aux_port}",
|
||||
listen_port=aux_port,
|
||||
target_host=self.target_host,
|
||||
target_port=aux_port,
|
||||
on_connect=lambda cid, p=aux_port: self._log_activity(f"Aux-{p}", f"connected: {cid}"),
|
||||
on_disconnect=lambda cid, p=aux_port: self._log_activity(f"Aux-{p}", f"disconnected: {cid}"),
|
||||
bind_address=self.bind_address,
|
||||
)
|
||||
)
|
||||
|
||||
# Bind/auth — respond with VP identity instead of proxying to printer.
|
||||
# The detect response contains the printer name, serial, model, and
|
||||
# bind status. Proxying it would leak the real printer's identity and
|
||||
@@ -1628,6 +1648,13 @@ class SlicerProxyManager:
|
||||
name="slicer_proxy_rtsp",
|
||||
),
|
||||
]
|
||||
for ap in self._aux_proxies:
|
||||
self._tasks.append(
|
||||
asyncio.create_task(
|
||||
run_with_logging(ap),
|
||||
name=f"slicer_proxy_aux_{ap.listen_port}",
|
||||
)
|
||||
)
|
||||
if self._bind_server:
|
||||
self._tasks.append(
|
||||
asyncio.create_task(
|
||||
@@ -1702,6 +1729,10 @@ class SlicerProxyManager:
|
||||
await self._rtsp_proxy.stop()
|
||||
self._rtsp_proxy = None
|
||||
|
||||
for ap in self._aux_proxies:
|
||||
await ap.stop()
|
||||
self._aux_proxies = []
|
||||
|
||||
if self._bind_server:
|
||||
await self._bind_server.stop()
|
||||
self._bind_server = None
|
||||
|
||||
Reference in New Issue
Block a user