v0.2.2.2 (#830)
This commit is contained in:
MartinNYHC
2026-03-27 09:27:47 +01:00
committed by GitHub
parent 8270030687
commit 67f91d94de
112 changed files with 18595 additions and 9993 deletions
@@ -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()
+136 -20
View File
@@ -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)
+2
View File
@@ -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}"
+18 -4
View File
@@ -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)
+7 -3
View File
@@ -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)
+178 -10
View File
@@ -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
+247
View File
@@ -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]}")
+99 -47
View File
@@ -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