printer_control.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  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 FanRequest(BaseModel):
  79. speed: int = Field(..., ge=0, le=100, description="Fan speed percentage (0-100)")
  80. class LightRequest(BaseModel):
  81. on: bool = Field(..., description="Light state: true=on, false=off")
  82. class CameraSettingRequest(BaseModel):
  83. enable: bool = Field(..., description="Enable or disable the setting")
  84. class HomeRequest(ConfirmableRequest):
  85. axes: str = Field(default="XYZ", description="Axes to home (e.g., 'XYZ', 'X', 'XY', 'Z')")
  86. class MoveRequest(ConfirmableRequest):
  87. axis: str = Field(..., pattern="^[XYZxyz]$", description="Axis to move: X, Y, or Z")
  88. distance: float = Field(..., ge=-100, le=100, description="Distance in mm (positive or negative)")
  89. speed: int = Field(default=3000, ge=100, le=10000, description="Movement speed in mm/min")
  90. class AMSLoadRequest(BaseModel):
  91. tray_id: int = Field(..., ge=0, le=254, description="Tray ID (0-15 for AMS, 254 for external)")
  92. class GcodeRequest(ConfirmableRequest):
  93. command: str = Field(..., min_length=1, max_length=500, description="G-code command(s)")
  94. # =============================================================================
  95. # Print Control Endpoints
  96. # =============================================================================
  97. @router.post("/{printer_id}/control/pause", response_model=ControlResponse)
  98. async def pause_print(
  99. printer_id: int,
  100. db: AsyncSession = Depends(get_db),
  101. ):
  102. """Pause the current print job."""
  103. await get_printer_or_404(printer_id, db)
  104. client = get_mqtt_client_or_503(printer_id)
  105. # Check if printer is actually printing
  106. if client.state.state != "RUNNING":
  107. raise HTTPException(status_code=400, detail="Printer is not currently printing")
  108. success = client.pause_print()
  109. return ControlResponse(
  110. success=success,
  111. message="Pause command sent" if success else "Failed to send pause command"
  112. )
  113. @router.post("/{printer_id}/control/resume", response_model=ControlResponse)
  114. async def resume_print(
  115. printer_id: int,
  116. db: AsyncSession = Depends(get_db),
  117. ):
  118. """Resume a paused 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 paused
  122. if client.state.state != "PAUSE":
  123. raise HTTPException(status_code=400, detail="Printer is not paused")
  124. success = client.resume_print()
  125. return ControlResponse(
  126. success=success,
  127. message="Resume command sent" if success else "Failed to send resume command"
  128. )
  129. @router.post("/{printer_id}/control/stop")
  130. async def stop_print(
  131. printer_id: int,
  132. request: ConfirmableRequest = None,
  133. db: AsyncSession = Depends(get_db),
  134. ):
  135. """Stop the current print job. Requires confirmation."""
  136. await get_printer_or_404(printer_id, db)
  137. client = get_mqtt_client_or_503(printer_id)
  138. # Check if printer is printing or paused
  139. if client.state.state not in ("RUNNING", "PAUSE"):
  140. raise HTTPException(status_code=400, detail="No active print to stop")
  141. # Require confirmation for stop
  142. if not request or not request.confirm_token:
  143. token = _create_confirmation_token(printer_id, "stop")
  144. return ConfirmationRequired(
  145. token=token,
  146. warning="This will abort the current print. The print cannot be resumed. Are you sure?"
  147. )
  148. if not _validate_confirmation_token(request.confirm_token, printer_id, "stop"):
  149. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  150. success = client.stop_print()
  151. return ControlResponse(
  152. success=success,
  153. message="Stop command sent" if success else "Failed to send stop command"
  154. )
  155. # =============================================================================
  156. # Temperature Control Endpoints
  157. # =============================================================================
  158. @router.post("/{printer_id}/control/temperature/bed", response_model=ControlResponse)
  159. async def set_bed_temperature(
  160. printer_id: int,
  161. request: TemperatureRequest,
  162. db: AsyncSession = Depends(get_db),
  163. ):
  164. """Set the bed target temperature."""
  165. await get_printer_or_404(printer_id, db)
  166. client = get_mqtt_client_or_503(printer_id)
  167. # Warn for high temperatures
  168. if request.target > 100 and not request.confirm_token:
  169. token = _create_confirmation_token(printer_id, "bed_temp")
  170. return ConfirmationRequired(
  171. token=token,
  172. warning=f"Setting bed to {request.target}°C is unusually high. Confirm?"
  173. )
  174. if request.target > 100:
  175. if not _validate_confirmation_token(request.confirm_token, printer_id, "bed_temp"):
  176. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  177. success = client.set_bed_temperature(request.target)
  178. return ControlResponse(
  179. success=success,
  180. message=f"Bed temperature set to {request.target}°C" if success else "Failed to set bed temperature"
  181. )
  182. @router.post("/{printer_id}/control/temperature/nozzle", response_model=ControlResponse)
  183. async def set_nozzle_temperature(
  184. printer_id: int,
  185. request: NozzleTemperatureRequest,
  186. db: AsyncSession = Depends(get_db),
  187. ):
  188. """Set the nozzle target temperature."""
  189. await get_printer_or_404(printer_id, db)
  190. client = get_mqtt_client_or_503(printer_id)
  191. # Warn for high temperatures
  192. if request.target > 280 and not request.confirm_token:
  193. token = _create_confirmation_token(printer_id, "nozzle_temp")
  194. return ConfirmationRequired(
  195. token=token,
  196. warning=f"Setting nozzle to {request.target}°C is very high. Confirm?"
  197. )
  198. if request.target > 280:
  199. if not _validate_confirmation_token(request.confirm_token, printer_id, "nozzle_temp"):
  200. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  201. success = client.set_nozzle_temperature(request.target, request.nozzle)
  202. return ControlResponse(
  203. success=success,
  204. message=f"Nozzle {request.nozzle} temperature set to {request.target}°C" if success else "Failed to set nozzle temperature"
  205. )
  206. # =============================================================================
  207. # Speed Control Endpoint
  208. # =============================================================================
  209. @router.post("/{printer_id}/control/speed", response_model=ControlResponse)
  210. async def set_print_speed(
  211. printer_id: int,
  212. request: SpeedRequest,
  213. db: AsyncSession = Depends(get_db),
  214. ):
  215. """Set the print speed mode."""
  216. await get_printer_or_404(printer_id, db)
  217. client = get_mqtt_client_or_503(printer_id)
  218. speed_names = {1: "Silent", 2: "Standard", 3: "Sport", 4: "Ludicrous"}
  219. success = client.set_print_speed(request.mode)
  220. return ControlResponse(
  221. success=success,
  222. message=f"Speed set to {speed_names[request.mode]}" if success else "Failed to set speed"
  223. )
  224. # =============================================================================
  225. # Fan Control Endpoints
  226. # =============================================================================
  227. @router.post("/{printer_id}/control/fan/part", response_model=ControlResponse)
  228. async def set_part_fan(
  229. printer_id: int,
  230. request: FanRequest,
  231. db: AsyncSession = Depends(get_db),
  232. ):
  233. """Set part cooling fan speed (0-100%)."""
  234. await get_printer_or_404(printer_id, db)
  235. client = get_mqtt_client_or_503(printer_id)
  236. # Convert percentage to 0-255
  237. speed_255 = int(request.speed * 255 / 100)
  238. success = client.set_part_fan(speed_255)
  239. return ControlResponse(
  240. success=success,
  241. message=f"Part fan set to {request.speed}%" if success else "Failed to set part fan"
  242. )
  243. @router.post("/{printer_id}/control/fan/aux", response_model=ControlResponse)
  244. async def set_aux_fan(
  245. printer_id: int,
  246. request: FanRequest,
  247. db: AsyncSession = Depends(get_db),
  248. ):
  249. """Set auxiliary fan speed (0-100%)."""
  250. await get_printer_or_404(printer_id, db)
  251. client = get_mqtt_client_or_503(printer_id)
  252. speed_255 = int(request.speed * 255 / 100)
  253. success = client.set_aux_fan(speed_255)
  254. return ControlResponse(
  255. success=success,
  256. message=f"Aux fan set to {request.speed}%" if success else "Failed to set aux fan"
  257. )
  258. @router.post("/{printer_id}/control/fan/chamber", response_model=ControlResponse)
  259. async def set_chamber_fan(
  260. printer_id: int,
  261. request: FanRequest,
  262. db: AsyncSession = Depends(get_db),
  263. ):
  264. """Set chamber fan speed (0-100%)."""
  265. await get_printer_or_404(printer_id, db)
  266. client = get_mqtt_client_or_503(printer_id)
  267. speed_255 = int(request.speed * 255 / 100)
  268. success = client.set_chamber_fan(speed_255)
  269. return ControlResponse(
  270. success=success,
  271. message=f"Chamber fan set to {request.speed}%" if success else "Failed to set chamber fan"
  272. )
  273. # =============================================================================
  274. # Light Control Endpoint
  275. # =============================================================================
  276. @router.post("/{printer_id}/control/light", response_model=ControlResponse)
  277. async def set_chamber_light(
  278. printer_id: int,
  279. request: LightRequest,
  280. db: AsyncSession = Depends(get_db),
  281. ):
  282. """Turn chamber light on or off."""
  283. await get_printer_or_404(printer_id, db)
  284. client = get_mqtt_client_or_503(printer_id)
  285. success = client.set_chamber_light(request.on)
  286. return ControlResponse(
  287. success=success,
  288. message=f"Light turned {'on' if request.on else 'off'}" if success else "Failed to control light"
  289. )
  290. # =============================================================================
  291. # Movement Control Endpoints
  292. # =============================================================================
  293. @router.post("/{printer_id}/control/home")
  294. async def home_axes(
  295. printer_id: int,
  296. request: HomeRequest = None,
  297. db: AsyncSession = Depends(get_db),
  298. ):
  299. """Home the specified axes."""
  300. await get_printer_or_404(printer_id, db)
  301. client = get_mqtt_client_or_503(printer_id)
  302. axes = (request.axes if request else "XYZ").upper()
  303. # Warn if homing during print
  304. if client.state.state in ("RUNNING", "PAUSE"):
  305. if not request or not request.confirm_token:
  306. token = _create_confirmation_token(printer_id, "home")
  307. return ConfirmationRequired(
  308. token=token,
  309. warning="Homing during an active print is not recommended. This may damage your print. Continue?"
  310. )
  311. if not _validate_confirmation_token(request.confirm_token, printer_id, "home"):
  312. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  313. success = client.home_axes(axes)
  314. return ControlResponse(
  315. success=success,
  316. message=f"Homing {axes}" if success else "Failed to send home command"
  317. )
  318. @router.post("/{printer_id}/control/move")
  319. async def move_axis(
  320. printer_id: int,
  321. request: MoveRequest,
  322. db: AsyncSession = Depends(get_db),
  323. ):
  324. """Move an axis by a relative distance."""
  325. await get_printer_or_404(printer_id, db)
  326. client = get_mqtt_client_or_503(printer_id)
  327. # Block movement during print unless confirmed
  328. if client.state.state in ("RUNNING", "PAUSE"):
  329. if not request.confirm_token:
  330. token = _create_confirmation_token(printer_id, "move")
  331. return ConfirmationRequired(
  332. token=token,
  333. warning="Manual movement during printing can damage your print. Are you sure?"
  334. )
  335. if not _validate_confirmation_token(request.confirm_token, printer_id, "move"):
  336. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  337. success = client.move_axis(request.axis.upper(), request.distance, request.speed)
  338. direction = "+" if request.distance > 0 else ""
  339. return ControlResponse(
  340. success=success,
  341. message=f"Moving {request.axis.upper()} {direction}{request.distance}mm" if success else "Failed to send move command"
  342. )
  343. @router.post("/{printer_id}/control/motors/disable")
  344. async def disable_motors(
  345. printer_id: int,
  346. request: ConfirmableRequest = None,
  347. db: AsyncSession = Depends(get_db),
  348. ):
  349. """Disable stepper motors. Warning: This will lose position."""
  350. await get_printer_or_404(printer_id, db)
  351. client = get_mqtt_client_or_503(printer_id)
  352. # Always require confirmation
  353. if not request or not request.confirm_token:
  354. token = _create_confirmation_token(printer_id, "disable_motors")
  355. return ConfirmationRequired(
  356. token=token,
  357. warning="Disabling motors will cause the printer to lose its position. You must home before printing. Continue?"
  358. )
  359. if not _validate_confirmation_token(request.confirm_token, printer_id, "disable_motors"):
  360. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  361. success = client.disable_motors()
  362. return ControlResponse(
  363. success=success,
  364. message="Motors disabled" if success else "Failed to disable motors"
  365. )
  366. @router.post("/{printer_id}/control/motors/enable", response_model=ControlResponse)
  367. async def enable_motors(
  368. printer_id: int,
  369. db: AsyncSession = Depends(get_db),
  370. ):
  371. """Enable stepper motors."""
  372. await get_printer_or_404(printer_id, db)
  373. client = get_mqtt_client_or_503(printer_id)
  374. success = client.enable_motors()
  375. return ControlResponse(
  376. success=success,
  377. message="Motors enabled" if success else "Failed to enable motors"
  378. )
  379. # =============================================================================
  380. # AMS Control Endpoints
  381. # =============================================================================
  382. @router.post("/{printer_id}/control/ams/load", response_model=ControlResponse)
  383. async def ams_load_filament(
  384. printer_id: int,
  385. request: AMSLoadRequest,
  386. db: AsyncSession = Depends(get_db),
  387. ):
  388. """Load filament from a specific AMS tray."""
  389. await get_printer_or_404(printer_id, db)
  390. client = get_mqtt_client_or_503(printer_id)
  391. # Don't allow during print
  392. if client.state.state == "RUNNING":
  393. raise HTTPException(status_code=400, detail="Cannot change filament during print")
  394. success = client.ams_load_filament(request.tray_id)
  395. return ControlResponse(
  396. success=success,
  397. message=f"Loading filament from tray {request.tray_id}" if success else "Failed to load filament"
  398. )
  399. @router.post("/{printer_id}/control/ams/unload", response_model=ControlResponse)
  400. async def ams_unload_filament(
  401. printer_id: int,
  402. db: AsyncSession = Depends(get_db),
  403. ):
  404. """Unload the currently loaded filament."""
  405. await get_printer_or_404(printer_id, db)
  406. client = get_mqtt_client_or_503(printer_id)
  407. # Don't allow during print
  408. if client.state.state == "RUNNING":
  409. raise HTTPException(status_code=400, detail="Cannot unload filament during print")
  410. success = client.ams_unload_filament()
  411. return ControlResponse(
  412. success=success,
  413. message="Unloading filament" if success else "Failed to unload filament"
  414. )
  415. # =============================================================================
  416. # Advanced: G-code Command
  417. # =============================================================================
  418. @router.post("/{printer_id}/control/gcode")
  419. async def send_gcode(
  420. printer_id: int,
  421. request: GcodeRequest,
  422. db: AsyncSession = Depends(get_db),
  423. ):
  424. """Send raw G-code command(s). Advanced users only."""
  425. await get_printer_or_404(printer_id, db)
  426. client = get_mqtt_client_or_503(printer_id)
  427. # Require confirmation for any G-code
  428. if not request.confirm_token:
  429. token = _create_confirmation_token(printer_id, "gcode")
  430. return ConfirmationRequired(
  431. token=token,
  432. warning="Sending raw G-code can damage your printer if used incorrectly. Are you sure?"
  433. )
  434. if not _validate_confirmation_token(request.confirm_token, printer_id, "gcode"):
  435. raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
  436. success = client.send_gcode(request.command)
  437. return ControlResponse(
  438. success=success,
  439. message="G-code sent" if success else "Failed to send G-code"
  440. )
  441. # =============================================================================
  442. # Camera Settings Endpoints
  443. # =============================================================================
  444. @router.post("/{printer_id}/control/camera/timelapse", response_model=ControlResponse)
  445. async def set_timelapse(
  446. printer_id: int,
  447. request: CameraSettingRequest,
  448. db: AsyncSession = Depends(get_db),
  449. ):
  450. """Enable or disable timelapse recording."""
  451. await get_printer_or_404(printer_id, db)
  452. client = get_mqtt_client_or_503(printer_id)
  453. success = client.set_timelapse(request.enable)
  454. return ControlResponse(
  455. success=success,
  456. message=f"Timelapse {'enabled' if request.enable else 'disabled'}" if success else "Failed to set timelapse"
  457. )
  458. @router.post("/{printer_id}/control/camera/liveview", response_model=ControlResponse)
  459. async def set_liveview(
  460. printer_id: int,
  461. request: CameraSettingRequest,
  462. db: AsyncSession = Depends(get_db),
  463. ):
  464. """Enable or disable live view / camera streaming."""
  465. await get_printer_or_404(printer_id, db)
  466. client = get_mqtt_client_or_503(printer_id)
  467. success = client.set_liveview(request.enable)
  468. return ControlResponse(
  469. success=success,
  470. message=f"Live view {'enabled' if request.enable else 'disabled'}" if success else "Failed to set live view"
  471. )
  472. # =============================================================================
  473. # Status Refresh Endpoint
  474. # =============================================================================
  475. @router.post("/{printer_id}/control/refresh", response_model=ControlResponse)
  476. async def refresh_status(
  477. printer_id: int,
  478. db: AsyncSession = Depends(get_db),
  479. ):
  480. """Request a full status update from the printer.
  481. This sends a 'pushall' command to get the latest data including nozzle info,
  482. AMS status, and all other printer state.
  483. """
  484. await get_printer_or_404(printer_id, db)
  485. success = printer_manager.request_status_update(printer_id)
  486. if not success:
  487. raise HTTPException(status_code=503, detail="Printer not connected")
  488. return ControlResponse(
  489. success=success,
  490. message="Status refresh requested"
  491. )