printer_control.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770
  1. """Printer control API endpoints for full printer control."""
  2. import logging
  3. import secrets
  4. import time
  5. from typing import Optional
  6. from fastapi import APIRouter, HTTPException, Depends
  7. from pydantic import BaseModel, Field
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy import select
  10. from backend.app.core.database import get_db
  11. from backend.app.models.printer import Printer
  12. from backend.app.services.printer_manager import printer_manager
  13. logger = logging.getLogger(__name__)
  14. router = APIRouter(prefix="/printers", tags=["printer-control"])
  15. # Store confirmation tokens with expiry: {token: (printer_id, action, expiry_time)}
  16. _confirmation_tokens: dict[str, tuple[int, str, float]] = {}
  17. CONFIRMATION_TOKEN_EXPIRY = 60 # seconds
  18. def _clean_expired_tokens():
  19. """Remove expired confirmation tokens."""
  20. now = time.time()
  21. expired = [t for t, (_, _, exp) in _confirmation_tokens.items() if now > exp]
  22. for token in expired:
  23. _confirmation_tokens.pop(token, None)
  24. def _create_confirmation_token(printer_id: int, action: str) -> str:
  25. """Create a confirmation token for dangerous operations."""
  26. _clean_expired_tokens()
  27. token = secrets.token_urlsafe(16)
  28. _confirmation_tokens[token] = (printer_id, action, time.time() + CONFIRMATION_TOKEN_EXPIRY)
  29. return token
  30. def _validate_confirmation_token(token: str, printer_id: int, action: str) -> bool:
  31. """Validate and consume a confirmation token."""
  32. _clean_expired_tokens()
  33. if token not in _confirmation_tokens:
  34. return False
  35. stored_printer_id, stored_action, expiry = _confirmation_tokens[token]
  36. if stored_printer_id != printer_id or stored_action != action:
  37. return False
  38. if time.time() > expiry:
  39. _confirmation_tokens.pop(token, None)
  40. return False
  41. # Consume the token
  42. _confirmation_tokens.pop(token, None)
  43. return True
  44. async def get_printer_or_404(printer_id: int, db: AsyncSession) -> Printer:
  45. """Get printer by ID or raise 404."""
  46. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  47. printer = result.scalar_one_or_none()
  48. if not printer:
  49. raise HTTPException(status_code=404, detail="Printer not found")
  50. return printer
  51. def get_mqtt_client_or_503(printer_id: int):
  52. """Get MQTT client for printer or raise 503."""
  53. client = printer_manager.get_client(printer_id)
  54. if not client:
  55. raise HTTPException(status_code=503, detail="Printer not connected")
  56. if not client.state.connected:
  57. raise HTTPException(status_code=503, detail="Printer connection lost")
  58. return client
  59. # =============================================================================
  60. # Request/Response Models
  61. # =============================================================================
  62. class ControlResponse(BaseModel):
  63. success: bool
  64. message: str
  65. class ConfirmationRequired(BaseModel):
  66. requires_confirmation: bool = True
  67. token: str
  68. warning: str
  69. expires_in: int = CONFIRMATION_TOKEN_EXPIRY
  70. class ConfirmableRequest(BaseModel):
  71. confirm_token: Optional[str] = None
  72. class TemperatureRequest(ConfirmableRequest):
  73. target: int = Field(..., ge=0, le=350, description="Target temperature in Celsius")
  74. class NozzleTemperatureRequest(TemperatureRequest):
  75. nozzle: int = Field(default=0, ge=0, le=1, description="Nozzle index (0 or 1 for dual nozzle)")
  76. class SpeedRequest(BaseModel):
  77. mode: int = Field(..., ge=1, le=4, description="Speed mode: 1=silent, 2=standard, 3=sport, 4=ludicrous")
  78. class ExtruderRequest(BaseModel):
  79. extruder: int = Field(..., ge=0, le=1, description="Extruder index (0=right, 1=left for H2D)")
  80. class FanRequest(BaseModel):
  81. speed: int = Field(..., ge=0, le=100, description="Fan speed percentage (0-100)")
  82. class LightRequest(BaseModel):
  83. on: bool = Field(..., description="Light state: true=on, false=off")
  84. class CameraSettingRequest(BaseModel):
  85. enable: bool = Field(..., description="Enable or disable the setting")
  86. class HomeRequest(ConfirmableRequest):
  87. axes: str = Field(default="XYZ", description="Axes to home (e.g., 'XYZ', 'X', 'XY', 'Z')")
  88. class MoveRequest(ConfirmableRequest):
  89. axis: str = Field(..., pattern="^[XYZxyz]$", description="Axis to move: X, Y, or Z")
  90. distance: float = Field(..., ge=-100, le=100, description="Distance in mm (positive or negative)")
  91. speed: int = Field(default=3000, ge=100, le=10000, description="Movement speed in mm/min")
  92. class AMSLoadRequest(BaseModel):
  93. tray_id: int = Field(..., ge=0, le=254, description="Tray ID (0-15 for AMS, 254 for external)")
  94. extruder_id: int | None = Field(default=None, ge=0, le=1, description="Extruder ID for dual-nozzle printers (0=right, 1=left)")
  95. class AMSRefreshTrayRequest(BaseModel):
  96. ams_id: int = Field(..., ge=0, le=128, description="AMS unit ID (0-3, or 128 for H2D external)")
  97. tray_id: int = Field(..., ge=0, le=3, description="Tray ID within the AMS (0-3)")
  98. class AMSFilamentSettingRequest(BaseModel):
  99. ams_id: int = Field(..., ge=0, le=128, description="AMS unit ID (0-3, or 128 for H2D external)")
  100. tray_id: int = Field(..., ge=0, le=3, description="Tray ID within the AMS (0-3)")
  101. tray_info_idx: str = Field(..., description="Filament preset ID (e.g., 'GFA00')")
  102. tray_type: str = Field(..., description="Filament type (e.g., 'PLA', 'PETG')")
  103. tray_sub_brands: str = Field(default="", description="Sub-brand name (e.g., 'PLA Basic')")
  104. tray_color: str = Field(..., description="Color in RRGGBBAA hex format")
  105. nozzle_temp_min: int = Field(..., ge=150, le=350, description="Minimum nozzle temperature")
  106. nozzle_temp_max: int = Field(..., ge=150, le=350, description="Maximum nozzle temperature")
  107. k: float = Field(..., ge=0, le=1, description="Pressure advance (K) value")
  108. class GcodeRequest(ConfirmableRequest):
  109. command: str = Field(..., min_length=1, max_length=500, description="G-code command(s)")
  110. # =============================================================================
  111. # Print Control Endpoints
  112. # =============================================================================
  113. @router.post("/{printer_id}/control/pause", response_model=ControlResponse)
  114. async def pause_print(
  115. printer_id: int,
  116. db: AsyncSession = Depends(get_db),
  117. ):
  118. """Pause the current print job."""
  119. await get_printer_or_404(printer_id, db)
  120. client = get_mqtt_client_or_503(printer_id)
  121. # Check if printer is actually printing
  122. if client.state.state != "RUNNING":
  123. raise HTTPException(status_code=400, detail="Printer is not currently printing")
  124. success = client.pause_print()
  125. return ControlResponse(
  126. success=success,
  127. message="Pause command sent" if success else "Failed to send pause command"
  128. )
  129. @router.post("/{printer_id}/control/resume", response_model=ControlResponse)
  130. async def resume_print(
  131. printer_id: int,
  132. db: AsyncSession = Depends(get_db),
  133. ):
  134. """Resume a paused print job."""
  135. await get_printer_or_404(printer_id, db)
  136. client = get_mqtt_client_or_503(printer_id)
  137. # Check if printer is actually paused
  138. if client.state.state != "PAUSE":
  139. raise HTTPException(status_code=400, detail="Printer is not paused")
  140. success = client.resume_print()
  141. return ControlResponse(
  142. success=success,
  143. message="Resume command sent" if success else "Failed to send resume command"
  144. )
  145. @router.post("/{printer_id}/control/stop")
  146. async def stop_print(
  147. printer_id: int,
  148. request: ConfirmableRequest = None,
  149. db: AsyncSession = Depends(get_db),
  150. ):
  151. """Stop the current print job. Requires confirmation."""
  152. await get_printer_or_404(printer_id, db)
  153. client = get_mqtt_client_or_503(printer_id)
  154. # Check if printer is printing or paused
  155. if client.state.state not in ("RUNNING", "PAUSE"):
  156. raise HTTPException(status_code=400, detail="No active print to stop")
  157. # Require confirmation for stop
  158. if not request or not request.confirm_token:
  159. token = _create_confirmation_token(printer_id, "stop")
  160. return ConfirmationRequired(
  161. token=token,
  162. warning="This will abort the current print. The print cannot be resumed. Are you sure?"
  163. )
  164. if not _validate_confirmation_token(request.confirm_token, printer_id, "stop"):
  165. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  166. success = client.stop_print()
  167. return ControlResponse(
  168. success=success,
  169. message="Stop command sent" if success else "Failed to send stop command"
  170. )
  171. # =============================================================================
  172. # Temperature Control Endpoints
  173. # =============================================================================
  174. @router.post("/{printer_id}/control/temperature/bed", response_model=ControlResponse)
  175. async def set_bed_temperature(
  176. printer_id: int,
  177. request: TemperatureRequest,
  178. db: AsyncSession = Depends(get_db),
  179. ):
  180. """Set the bed target temperature."""
  181. await get_printer_or_404(printer_id, db)
  182. client = get_mqtt_client_or_503(printer_id)
  183. # Warn for high temperatures
  184. if request.target > 100 and not request.confirm_token:
  185. token = _create_confirmation_token(printer_id, "bed_temp")
  186. return ConfirmationRequired(
  187. token=token,
  188. warning=f"Setting bed to {request.target}°C is unusually high. Confirm?"
  189. )
  190. if request.target > 100:
  191. if not _validate_confirmation_token(request.confirm_token, printer_id, "bed_temp"):
  192. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  193. success = client.set_bed_temperature(request.target)
  194. return ControlResponse(
  195. success=success,
  196. message=f"Bed temperature set to {request.target}°C" if success else "Failed to set bed temperature"
  197. )
  198. @router.post("/{printer_id}/control/temperature/nozzle", response_model=ControlResponse)
  199. async def set_nozzle_temperature(
  200. printer_id: int,
  201. request: NozzleTemperatureRequest,
  202. db: AsyncSession = Depends(get_db),
  203. ):
  204. """Set the nozzle target temperature."""
  205. await get_printer_or_404(printer_id, db)
  206. client = get_mqtt_client_or_503(printer_id)
  207. # Warn for high temperatures
  208. if request.target > 280 and not request.confirm_token:
  209. token = _create_confirmation_token(printer_id, "nozzle_temp")
  210. return ConfirmationRequired(
  211. token=token,
  212. warning=f"Setting nozzle to {request.target}°C is very high. Confirm?"
  213. )
  214. if request.target > 280:
  215. if not _validate_confirmation_token(request.confirm_token, printer_id, "nozzle_temp"):
  216. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  217. success = client.set_nozzle_temperature(request.target, request.nozzle)
  218. return ControlResponse(
  219. success=success,
  220. message=f"Nozzle {request.nozzle} temperature set to {request.target}°C" if success else "Failed to set nozzle temperature"
  221. )
  222. @router.post("/{printer_id}/control/temperature/chamber", response_model=ControlResponse)
  223. async def set_chamber_temperature(
  224. printer_id: int,
  225. request: TemperatureRequest,
  226. db: AsyncSession = Depends(get_db),
  227. ):
  228. """Set the chamber target temperature."""
  229. await get_printer_or_404(printer_id, db)
  230. client = get_mqtt_client_or_503(printer_id)
  231. # Warn for high temperatures (chamber typically maxes around 60°C)
  232. if request.target > 60 and not request.confirm_token:
  233. token = _create_confirmation_token(printer_id, "chamber_temp")
  234. return ConfirmationRequired(
  235. token=token,
  236. warning=f"Setting chamber to {request.target}°C is very high. Confirm?"
  237. )
  238. if request.target > 60:
  239. if not _validate_confirmation_token(request.confirm_token, printer_id, "chamber_temp"):
  240. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  241. success = client.set_chamber_temperature(request.target)
  242. return ControlResponse(
  243. success=success,
  244. message=f"Chamber temperature set to {request.target}°C" if success else "Failed to set chamber temperature"
  245. )
  246. # =============================================================================
  247. # Speed Control Endpoint
  248. # =============================================================================
  249. @router.post("/{printer_id}/control/speed", response_model=ControlResponse)
  250. async def set_print_speed(
  251. printer_id: int,
  252. request: SpeedRequest,
  253. db: AsyncSession = Depends(get_db),
  254. ):
  255. """Set the print speed mode."""
  256. await get_printer_or_404(printer_id, db)
  257. client = get_mqtt_client_or_503(printer_id)
  258. speed_names = {1: "Silent", 2: "Standard", 3: "Sport", 4: "Ludicrous"}
  259. success = client.set_print_speed(request.mode)
  260. return ControlResponse(
  261. success=success,
  262. message=f"Speed set to {speed_names[request.mode]}" if success else "Failed to set speed"
  263. )
  264. # =============================================================================
  265. # Extruder Control Endpoint
  266. # =============================================================================
  267. @router.post("/{printer_id}/control/extruder", response_model=ControlResponse)
  268. async def select_extruder(
  269. printer_id: int,
  270. request: ExtruderRequest,
  271. db: AsyncSession = Depends(get_db),
  272. ):
  273. """Select the active extruder for dual-nozzle printers."""
  274. await get_printer_or_404(printer_id, db)
  275. client = get_mqtt_client_or_503(printer_id)
  276. extruder_names = {0: "Right", 1: "Left"}
  277. success = client.select_extruder(request.extruder)
  278. return ControlResponse(
  279. success=success,
  280. message=f"Selected {extruder_names[request.extruder]} extruder" if success else "Failed to select extruder"
  281. )
  282. # =============================================================================
  283. # Fan Control Endpoints
  284. # =============================================================================
  285. @router.post("/{printer_id}/control/fan/part", response_model=ControlResponse)
  286. async def set_part_fan(
  287. printer_id: int,
  288. request: FanRequest,
  289. db: AsyncSession = Depends(get_db),
  290. ):
  291. """Set part cooling fan speed (0-100%)."""
  292. await get_printer_or_404(printer_id, db)
  293. client = get_mqtt_client_or_503(printer_id)
  294. # Convert percentage to 0-255
  295. speed_255 = int(request.speed * 255 / 100)
  296. success = client.set_part_fan(speed_255)
  297. return ControlResponse(
  298. success=success,
  299. message=f"Part fan set to {request.speed}%" if success else "Failed to set part fan"
  300. )
  301. @router.post("/{printer_id}/control/fan/aux", response_model=ControlResponse)
  302. async def set_aux_fan(
  303. printer_id: int,
  304. request: FanRequest,
  305. db: AsyncSession = Depends(get_db),
  306. ):
  307. """Set auxiliary fan speed (0-100%)."""
  308. await get_printer_or_404(printer_id, db)
  309. client = get_mqtt_client_or_503(printer_id)
  310. speed_255 = int(request.speed * 255 / 100)
  311. success = client.set_aux_fan(speed_255)
  312. return ControlResponse(
  313. success=success,
  314. message=f"Aux fan set to {request.speed}%" if success else "Failed to set aux fan"
  315. )
  316. @router.post("/{printer_id}/control/fan/chamber", response_model=ControlResponse)
  317. async def set_chamber_fan(
  318. printer_id: int,
  319. request: FanRequest,
  320. db: AsyncSession = Depends(get_db),
  321. ):
  322. """Set chamber fan speed (0-100%)."""
  323. await get_printer_or_404(printer_id, db)
  324. client = get_mqtt_client_or_503(printer_id)
  325. speed_255 = int(request.speed * 255 / 100)
  326. success = client.set_chamber_fan(speed_255)
  327. return ControlResponse(
  328. success=success,
  329. message=f"Chamber fan set to {request.speed}%" if success else "Failed to set chamber fan"
  330. )
  331. # =============================================================================
  332. # Air Conditioning Control Endpoint
  333. # =============================================================================
  334. class AirductModeRequest(BaseModel):
  335. mode: str # "cooling" or "heating"
  336. @router.post("/{printer_id}/control/airduct", response_model=ControlResponse)
  337. async def set_airduct_mode(
  338. printer_id: int,
  339. request: AirductModeRequest,
  340. db: AsyncSession = Depends(get_db),
  341. ):
  342. """Set air conditioning mode (cooling or heating).
  343. - Cooling: Suitable for PLA/PETG/TPU, filters and cools chamber air
  344. - Heating: Suitable for ABS/ASA/PC/PA, circulates and heats chamber air, closes top exhaust flap
  345. """
  346. await get_printer_or_404(printer_id, db)
  347. client = get_mqtt_client_or_503(printer_id)
  348. if request.mode not in ("cooling", "heating"):
  349. raise HTTPException(status_code=400, detail="Mode must be 'cooling' or 'heating'")
  350. success = client.set_airduct_mode(request.mode)
  351. return ControlResponse(
  352. success=success,
  353. message=f"Air conditioning set to {request.mode}" if success else "Failed to set air conditioning mode"
  354. )
  355. # =============================================================================
  356. # Light Control Endpoint
  357. # =============================================================================
  358. @router.post("/{printer_id}/control/light", response_model=ControlResponse)
  359. async def set_chamber_light(
  360. printer_id: int,
  361. request: LightRequest,
  362. db: AsyncSession = Depends(get_db),
  363. ):
  364. """Turn chamber light on or off."""
  365. await get_printer_or_404(printer_id, db)
  366. client = get_mqtt_client_or_503(printer_id)
  367. success = client.set_chamber_light(request.on)
  368. return ControlResponse(
  369. success=success,
  370. message=f"Light turned {'on' if request.on else 'off'}" if success else "Failed to control light"
  371. )
  372. # =============================================================================
  373. # Movement Control Endpoints
  374. # =============================================================================
  375. @router.post("/{printer_id}/control/home")
  376. async def home_axes(
  377. printer_id: int,
  378. request: HomeRequest = None,
  379. db: AsyncSession = Depends(get_db),
  380. ):
  381. """Home the specified axes."""
  382. await get_printer_or_404(printer_id, db)
  383. client = get_mqtt_client_or_503(printer_id)
  384. axes = (request.axes if request else "XYZ").upper()
  385. # Warn if homing during print
  386. if client.state.state in ("RUNNING", "PAUSE"):
  387. if not request or not request.confirm_token:
  388. token = _create_confirmation_token(printer_id, "home")
  389. return ConfirmationRequired(
  390. token=token,
  391. warning="Homing during an active print is not recommended. This may damage your print. Continue?"
  392. )
  393. if not _validate_confirmation_token(request.confirm_token, printer_id, "home"):
  394. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  395. success = client.home_axes(axes)
  396. return ControlResponse(
  397. success=success,
  398. message=f"Homing {axes}" if success else "Failed to send home command"
  399. )
  400. @router.post("/{printer_id}/control/move")
  401. async def move_axis(
  402. printer_id: int,
  403. request: MoveRequest,
  404. db: AsyncSession = Depends(get_db),
  405. ):
  406. """Move an axis by a relative distance."""
  407. await get_printer_or_404(printer_id, db)
  408. client = get_mqtt_client_or_503(printer_id)
  409. # Block movement during print unless confirmed
  410. if client.state.state in ("RUNNING", "PAUSE"):
  411. if not request.confirm_token:
  412. token = _create_confirmation_token(printer_id, "move")
  413. return ConfirmationRequired(
  414. token=token,
  415. warning="Manual movement during printing can damage your print. Are you sure?"
  416. )
  417. if not _validate_confirmation_token(request.confirm_token, printer_id, "move"):
  418. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  419. success = client.move_axis(request.axis.upper(), request.distance, request.speed)
  420. direction = "+" if request.distance > 0 else ""
  421. return ControlResponse(
  422. success=success,
  423. message=f"Moving {request.axis.upper()} {direction}{request.distance}mm" if success else "Failed to send move command"
  424. )
  425. @router.post("/{printer_id}/control/motors/disable")
  426. async def disable_motors(
  427. printer_id: int,
  428. request: ConfirmableRequest = None,
  429. db: AsyncSession = Depends(get_db),
  430. ):
  431. """Disable stepper motors. Warning: This will lose position."""
  432. await get_printer_or_404(printer_id, db)
  433. client = get_mqtt_client_or_503(printer_id)
  434. # Always require confirmation
  435. if not request or not request.confirm_token:
  436. token = _create_confirmation_token(printer_id, "disable_motors")
  437. return ConfirmationRequired(
  438. token=token,
  439. warning="Disabling motors will cause the printer to lose its position. You must home before printing. Continue?"
  440. )
  441. if not _validate_confirmation_token(request.confirm_token, printer_id, "disable_motors"):
  442. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  443. success = client.disable_motors()
  444. return ControlResponse(
  445. success=success,
  446. message="Motors disabled" if success else "Failed to disable motors"
  447. )
  448. @router.post("/{printer_id}/control/motors/enable", response_model=ControlResponse)
  449. async def enable_motors(
  450. printer_id: int,
  451. db: AsyncSession = Depends(get_db),
  452. ):
  453. """Enable stepper motors."""
  454. await get_printer_or_404(printer_id, db)
  455. client = get_mqtt_client_or_503(printer_id)
  456. success = client.enable_motors()
  457. return ControlResponse(
  458. success=success,
  459. message="Motors enabled" if success else "Failed to enable motors"
  460. )
  461. # =============================================================================
  462. # AMS Control Endpoints
  463. # =============================================================================
  464. @router.post("/{printer_id}/control/ams/load", response_model=ControlResponse)
  465. async def ams_load_filament(
  466. printer_id: int,
  467. request: AMSLoadRequest,
  468. db: AsyncSession = Depends(get_db),
  469. ):
  470. """Load filament from a specific AMS tray."""
  471. await get_printer_or_404(printer_id, db)
  472. client = get_mqtt_client_or_503(printer_id)
  473. # Don't allow during print
  474. if client.state.state == "RUNNING":
  475. raise HTTPException(status_code=400, detail="Cannot change filament during print")
  476. success = client.ams_load_filament(request.tray_id, request.extruder_id)
  477. extruder_info = f" to extruder {request.extruder_id}" if request.extruder_id is not None else ""
  478. return ControlResponse(
  479. success=success,
  480. message=f"Loading filament from tray {request.tray_id}{extruder_info}" if success else "Failed to load filament"
  481. )
  482. @router.post("/{printer_id}/control/ams/unload", response_model=ControlResponse)
  483. async def ams_unload_filament(
  484. printer_id: int,
  485. db: AsyncSession = Depends(get_db),
  486. ):
  487. """Unload the currently loaded filament."""
  488. await get_printer_or_404(printer_id, db)
  489. client = get_mqtt_client_or_503(printer_id)
  490. # Don't allow during print
  491. if client.state.state == "RUNNING":
  492. raise HTTPException(status_code=400, detail="Cannot unload filament during print")
  493. success = client.ams_unload_filament()
  494. return ControlResponse(
  495. success=success,
  496. message="Unloading filament" if success else "Failed to unload filament"
  497. )
  498. @router.post("/{printer_id}/control/ams/refresh-tray", response_model=ControlResponse)
  499. async def ams_refresh_tray(
  500. printer_id: int,
  501. request: AMSRefreshTrayRequest,
  502. db: AsyncSession = Depends(get_db),
  503. ):
  504. """Trigger RFID re-read for a specific AMS tray."""
  505. await get_printer_or_404(printer_id, db)
  506. client = get_mqtt_client_or_503(printer_id)
  507. success, message = client.ams_refresh_tray(request.ams_id, request.tray_id)
  508. return ControlResponse(success=success, message=message)
  509. @router.post("/{printer_id}/control/ams/filament-setting", response_model=ControlResponse)
  510. async def ams_set_filament_setting(
  511. printer_id: int,
  512. request: AMSFilamentSettingRequest,
  513. db: AsyncSession = Depends(get_db),
  514. ):
  515. """Set filament settings for an AMS tray including K (pressure advance) value."""
  516. await get_printer_or_404(printer_id, db)
  517. client = get_mqtt_client_or_503(printer_id)
  518. success = client.ams_set_filament_setting(
  519. ams_id=request.ams_id,
  520. tray_id=request.tray_id,
  521. tray_info_idx=request.tray_info_idx,
  522. tray_type=request.tray_type,
  523. tray_sub_brands=request.tray_sub_brands,
  524. tray_color=request.tray_color,
  525. nozzle_temp_min=request.nozzle_temp_min,
  526. nozzle_temp_max=request.nozzle_temp_max,
  527. k=request.k,
  528. )
  529. return ControlResponse(
  530. success=success,
  531. message=f"Updated AMS {request.ams_id} tray {request.tray_id} with K={request.k}" if success else "Failed to update filament setting"
  532. )
  533. # =============================================================================
  534. # Advanced: G-code Command
  535. # =============================================================================
  536. @router.post("/{printer_id}/control/gcode")
  537. async def send_gcode(
  538. printer_id: int,
  539. request: GcodeRequest,
  540. db: AsyncSession = Depends(get_db),
  541. ):
  542. """Send raw G-code command(s). Advanced users only."""
  543. await get_printer_or_404(printer_id, db)
  544. client = get_mqtt_client_or_503(printer_id)
  545. # Require confirmation for any G-code
  546. if not request.confirm_token:
  547. token = _create_confirmation_token(printer_id, "gcode")
  548. return ConfirmationRequired(
  549. token=token,
  550. warning="Sending raw G-code can damage your printer if used incorrectly. Are you sure?"
  551. )
  552. if not _validate_confirmation_token(request.confirm_token, printer_id, "gcode"):
  553. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  554. success = client.send_gcode(request.command)
  555. return ControlResponse(
  556. success=success,
  557. message="G-code sent" if success else "Failed to send G-code"
  558. )
  559. # =============================================================================
  560. # Camera Settings Endpoints
  561. # =============================================================================
  562. @router.post("/{printer_id}/control/camera/timelapse", response_model=ControlResponse)
  563. async def set_timelapse(
  564. printer_id: int,
  565. request: CameraSettingRequest,
  566. db: AsyncSession = Depends(get_db),
  567. ):
  568. """Enable or disable timelapse recording."""
  569. await get_printer_or_404(printer_id, db)
  570. client = get_mqtt_client_or_503(printer_id)
  571. success = client.set_timelapse(request.enable)
  572. return ControlResponse(
  573. success=success,
  574. message=f"Timelapse {'enabled' if request.enable else 'disabled'}" if success else "Failed to set timelapse"
  575. )
  576. @router.post("/{printer_id}/control/camera/liveview", response_model=ControlResponse)
  577. async def set_liveview(
  578. printer_id: int,
  579. request: CameraSettingRequest,
  580. db: AsyncSession = Depends(get_db),
  581. ):
  582. """Enable or disable live view / camera streaming."""
  583. await get_printer_or_404(printer_id, db)
  584. client = get_mqtt_client_or_503(printer_id)
  585. success = client.set_liveview(request.enable)
  586. return ControlResponse(
  587. success=success,
  588. message=f"Live view {'enabled' if request.enable else 'disabled'}" if success else "Failed to set live view"
  589. )
  590. # =============================================================================
  591. # Status Refresh Endpoint
  592. # =============================================================================
  593. @router.post("/{printer_id}/control/refresh", response_model=ControlResponse)
  594. async def refresh_status(
  595. printer_id: int,
  596. db: AsyncSession = Depends(get_db),
  597. ):
  598. """Request a full status update from the printer.
  599. This sends a 'pushall' command to get the latest data including nozzle info,
  600. AMS status, and all other printer state.
  601. """
  602. await get_printer_or_404(printer_id, db)
  603. success = printer_manager.request_status_update(printer_id)
  604. if not success:
  605. raise HTTPException(status_code=503, detail="Printer not connected")
  606. return ControlResponse(
  607. success=success,
  608. message="Status refresh requested"
  609. )