CalibrationModal.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. import { useEffect, useState } from 'react';
  2. import { useMutation } from '@tanstack/react-query';
  3. import { api } from '../../api/client';
  4. import type { Printer, PrinterStatus } from '../../api/client';
  5. import { X, Loader2, AlertTriangle, Check } from 'lucide-react';
  6. import { Card, CardContent } from '../Card';
  7. interface CalibrationModalProps {
  8. printer: Printer;
  9. status: PrinterStatus | null | undefined;
  10. onClose: () => void;
  11. }
  12. // Calibration stages that indicate active calibration
  13. const CALIBRATION_STAGES = new Set([1, 3, 13, 25, 39, 40, 47, 48, 50]);
  14. // Checkbox component matching Bambu Studio style
  15. function Checkbox({
  16. checked,
  17. onChange,
  18. disabled,
  19. }: {
  20. checked: boolean;
  21. onChange: (checked: boolean) => void;
  22. disabled?: boolean;
  23. }) {
  24. return (
  25. <button
  26. onClick={() => !disabled && onChange(!checked)}
  27. disabled={disabled}
  28. className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
  29. checked
  30. ? 'bg-bambu-green border-bambu-green'
  31. : 'bg-transparent border-bambu-gray'
  32. } ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
  33. >
  34. {checked && (
  35. <svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
  36. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  37. </svg>
  38. )}
  39. </button>
  40. );
  41. }
  42. // Timeline step component - matches Bambu Studio style
  43. function TimelineStep({
  44. step,
  45. name,
  46. isActive,
  47. isComplete,
  48. isLast,
  49. }: {
  50. step: number;
  51. name: string;
  52. isActive: boolean;
  53. isComplete: boolean;
  54. isLast: boolean;
  55. }) {
  56. return (
  57. <div className="flex items-start gap-3">
  58. {/* Circle and line container */}
  59. <div className="flex flex-col items-center">
  60. {/* Number circle */}
  61. <div
  62. className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${
  63. isActive || isComplete
  64. ? 'bg-bambu-green text-white'
  65. : 'bg-bambu-green text-white'
  66. }`}
  67. >
  68. {step}
  69. </div>
  70. {/* Vertical connecting line */}
  71. {!isLast && (
  72. <div className={`w-0.5 h-6 ${isComplete ? 'bg-bambu-green' : 'bg-bambu-gray/30'}`} />
  73. )}
  74. </div>
  75. {/* Step name */}
  76. <span
  77. className={`text-sm pt-0.5 ${
  78. isActive ? 'text-white font-semibold' : 'text-bambu-gray'
  79. }`}
  80. >
  81. {name}
  82. </span>
  83. </div>
  84. );
  85. }
  86. export function CalibrationModal({ printer, status, onClose }: CalibrationModalProps) {
  87. const isConnected = status?.connected ?? false;
  88. const isDualNozzle = printer.nozzle_count === 2;
  89. const currentStage = status?.stg_cur ?? -1;
  90. // Track if we've started calibration (to switch to progress view)
  91. const [calibrationStarted, setCalibrationStarted] = useState(false);
  92. // Track if we've seen the printer actually enter calibration mode
  93. const [seenCalibrating, setSeenCalibrating] = useState(false);
  94. // Track if calibration has completed
  95. const [calibrationCompleted, setCalibrationCompleted] = useState(false);
  96. // Calibration options state - restore from localStorage if calibration is in progress
  97. const storageKey = `calibration_options_${printer.id}`;
  98. const savedOptions = typeof window !== 'undefined' ? localStorage.getItem(storageKey) : null;
  99. const parsedOptions = savedOptions ? JSON.parse(savedOptions) : null;
  100. const [bedLeveling, setBedLeveling] = useState(parsedOptions?.bedLeveling ?? true);
  101. const [vibration, setVibration] = useState(parsedOptions?.vibration ?? true);
  102. const [motorNoise, setMotorNoise] = useState(parsedOptions?.motorNoise ?? true);
  103. const [nozzleOffset, setNozzleOffset] = useState(parsedOptions?.nozzleOffset ?? isDualNozzle);
  104. const [highTempHeatbed, setHighTempHeatbed] = useState(parsedOptions?.highTempHeatbed ?? false);
  105. // Track if we've initialized based on calibration state
  106. const [initialized, setInitialized] = useState(false);
  107. // Detect if printer is currently calibrating
  108. // Check both stg_cur being a calibration stage AND state being RUNNING
  109. // (printer may keep stg_cur at last calibration stage after completion)
  110. const printerState = status?.state;
  111. const isCalibrating = CALIBRATION_STAGES.has(currentStage) && printerState === 'RUNNING';
  112. // If calibration is already in progress when modal opens, set tracking state
  113. // Checkbox values are preserved from localStorage
  114. useEffect(() => {
  115. if (!initialized && isCalibrating) {
  116. setSeenCalibrating(true);
  117. setCalibrationStarted(true);
  118. setInitialized(true);
  119. } else if (!initialized && !isCalibrating) {
  120. setInitialized(true);
  121. }
  122. }, [initialized, isCalibrating]);
  123. // Track when printer actually enters calibration mode
  124. useEffect(() => {
  125. if (isCalibrating && !seenCalibrating) {
  126. setSeenCalibrating(true);
  127. setCalibrationCompleted(false);
  128. }
  129. }, [isCalibrating, seenCalibrating]);
  130. // Auto-detect if calibration was started externally (e.g., from touchscreen)
  131. useEffect(() => {
  132. if (isCalibrating && !calibrationStarted) {
  133. setCalibrationStarted(true);
  134. }
  135. }, [isCalibrating, calibrationStarted]);
  136. // Detect when calibration completes:
  137. // - Must have seen calibration actually running (seenCalibrating is true)
  138. // - Now isCalibrating is false (stg_cur left calibration stages OR state is no longer RUNNING)
  139. useEffect(() => {
  140. if (seenCalibrating && !isCalibrating && !calibrationCompleted) {
  141. setCalibrationCompleted(true);
  142. }
  143. }, [seenCalibrating, isCalibrating, calibrationCompleted]);
  144. // Reset function to allow starting a new calibration
  145. const resetCalibration = () => {
  146. localStorage.removeItem(storageKey);
  147. setCalibrationStarted(false);
  148. setSeenCalibrating(false);
  149. setCalibrationCompleted(false);
  150. // Reset to defaults
  151. setBedLeveling(true);
  152. setVibration(true);
  153. setMotorNoise(true);
  154. setNozzleOffset(isDualNozzle);
  155. setHighTempHeatbed(false);
  156. };
  157. // Close on Escape key
  158. useEffect(() => {
  159. const handleKeyDown = (e: KeyboardEvent) => {
  160. if (e.key === 'Escape') onClose();
  161. };
  162. window.addEventListener('keydown', handleKeyDown);
  163. return () => window.removeEventListener('keydown', handleKeyDown);
  164. }, [onClose]);
  165. // Start calibration mutation
  166. const calibrationMutation = useMutation({
  167. mutationFn: () =>
  168. api.startCalibration(printer.id, {
  169. bed_leveling: bedLeveling,
  170. vibration: vibration,
  171. motor_noise: motorNoise,
  172. nozzle_offset: nozzleOffset,
  173. high_temp_heatbed: highTempHeatbed,
  174. }),
  175. onSuccess: () => {
  176. // Save selected options to localStorage so they persist across modal close/open
  177. localStorage.setItem(storageKey, JSON.stringify({
  178. bedLeveling, vibration, motorNoise, nozzleOffset, highTempHeatbed
  179. }));
  180. setCalibrationStarted(true);
  181. },
  182. });
  183. const hasSelection = bedLeveling || vibration || motorNoise || nozzleOffset || highTempHeatbed;
  184. const canStart = isConnected && hasSelection && !calibrationMutation.isPending && !isCalibrating && !calibrationCompleted;
  185. // Build expected calibration flow based on selections
  186. // These are in the typical order the printer performs them
  187. const expectedFlow: { name: string; stages: number[] }[] = [];
  188. expectedFlow.push({ name: 'Homing toolhead', stages: [13] });
  189. if (bedLeveling || highTempHeatbed) {
  190. expectedFlow.push({ name: 'Cooling heatbed', stages: [50] });
  191. }
  192. if (bedLeveling) {
  193. expectedFlow.push({ name: 'Auto bed leveling - phase 1', stages: [1, 47] });
  194. }
  195. if (motorNoise) {
  196. expectedFlow.push({ name: 'Motor noise cancellation', stages: [25] });
  197. }
  198. if (vibration) {
  199. expectedFlow.push({ name: 'Vibration compensation', stages: [3] });
  200. }
  201. if (bedLeveling) {
  202. expectedFlow.push({ name: 'Auto bed leveling - phase 2', stages: [48] });
  203. }
  204. if (isDualNozzle && nozzleOffset) {
  205. expectedFlow.push({ name: 'Nozzle offset calibration', stages: [39] });
  206. }
  207. if (highTempHeatbed) {
  208. expectedFlow.push({ name: 'High-temp heatbed calibration', stages: [40] });
  209. }
  210. // Find current step index
  211. const currentStepIndex = expectedFlow.findIndex((step) =>
  212. step.stages.includes(currentStage)
  213. );
  214. return (
  215. <div
  216. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  217. onClick={onClose}
  218. >
  219. <Card className="w-full max-w-3xl max-h-[90vh] flex flex-col" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
  220. <CardContent className="p-0 flex flex-col h-full">
  221. {/* Header */}
  222. <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
  223. <span className="text-sm font-medium text-white">Calibration</span>
  224. <button
  225. onClick={onClose}
  226. className="p-1 rounded text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white"
  227. >
  228. <X className="w-4 h-4" />
  229. </button>
  230. </div>
  231. {/* Content */}
  232. <div className="flex-1 overflow-y-auto p-6">
  233. {!isConnected && (
  234. <div className="flex items-center gap-2 p-3 mb-4 bg-red-500/20 border border-red-500/50 rounded text-red-400">
  235. <AlertTriangle className="w-4 h-4" />
  236. <span className="text-sm">Printer not connected. Calibration cannot be started.</span>
  237. </div>
  238. )}
  239. <div className="grid grid-cols-2 gap-8">
  240. {/* Left column - Calibration step selection */}
  241. <div>
  242. <h3 className="text-base font-semibold text-white mb-4">Calibration step selection</h3>
  243. <div className="space-y-4">
  244. {/* Bed leveling */}
  245. <div className="flex items-center gap-3">
  246. <Checkbox
  247. checked={bedLeveling}
  248. onChange={setBedLeveling}
  249. disabled={!isConnected || isCalibrating}
  250. />
  251. <span className="text-sm text-white">Bed leveling</span>
  252. </div>
  253. {/* Vibration compensation */}
  254. <div className="flex items-center gap-3">
  255. <Checkbox
  256. checked={vibration}
  257. onChange={setVibration}
  258. disabled={!isConnected || isCalibrating}
  259. />
  260. <span className="text-sm text-white">Vibration compensation</span>
  261. </div>
  262. {/* Motor noise cancellation */}
  263. <div className="flex items-center gap-3">
  264. <Checkbox
  265. checked={motorNoise}
  266. onChange={setMotorNoise}
  267. disabled={!isConnected || isCalibrating}
  268. />
  269. <span className="text-sm text-white">Motor noise cancellation</span>
  270. </div>
  271. {/* Nozzle offset calibration - only for dual nozzle printers */}
  272. {isDualNozzle && (
  273. <div className="flex items-center gap-3">
  274. <Checkbox
  275. checked={nozzleOffset}
  276. onChange={setNozzleOffset}
  277. disabled={!isConnected || isCalibrating}
  278. />
  279. <span className="text-sm text-white">Nozzle offset calibration</span>
  280. </div>
  281. )}
  282. {/* High-temperature Heatbed Calibration */}
  283. <div className="flex items-center gap-3">
  284. <Checkbox
  285. checked={highTempHeatbed}
  286. onChange={setHighTempHeatbed}
  287. disabled={!isConnected || isCalibrating}
  288. />
  289. <span className="text-sm text-white whitespace-nowrap">High-temperature Heatbed Calibration</span>
  290. </div>
  291. </div>
  292. {/* Calibration program description */}
  293. <div className="mt-6">
  294. <h4 className="text-sm font-semibold text-white mb-2">Calibration program</h4>
  295. <p className="text-xs text-bambu-gray">
  296. The calibration program detects the status of your device automatically to minimize deviation.
  297. It keeps the device performing optimally.
  298. </p>
  299. </div>
  300. </div>
  301. {/* Right column - Calibration Flow & Start button */}
  302. <div className="flex flex-col">
  303. <h3 className="text-base font-semibold text-bambu-green mb-4 text-center border-b border-bambu-dark-tertiary pb-2">
  304. Calibration Flow
  305. </h3>
  306. {/* Timeline progress indicator */}
  307. <div className="flex-1 py-4 pl-4">
  308. {hasSelection ? (
  309. <div className="space-y-0">
  310. {expectedFlow.map((step, index) => {
  311. const isActive = calibrationStarted && !calibrationCompleted && step.stages.includes(currentStage);
  312. const isComplete = calibrationCompleted || (calibrationStarted && currentStepIndex > index);
  313. return (
  314. <TimelineStep
  315. key={step.name}
  316. step={index + 1}
  317. name={step.name}
  318. isActive={isActive}
  319. isComplete={isComplete}
  320. isLast={index === expectedFlow.length - 1}
  321. />
  322. );
  323. })}
  324. {/* Show current stage name if it's not in expected flow */}
  325. {currentStage >= 0 && currentStepIndex === -1 && status?.stg_cur_name && (
  326. <div className="mt-4 text-xs text-bambu-gray">
  327. Current: {status.stg_cur_name}
  328. </div>
  329. )}
  330. </div>
  331. ) : (
  332. <div className="flex items-center justify-center h-full text-sm text-bambu-gray italic">
  333. Select calibration steps
  334. </div>
  335. )}
  336. </div>
  337. {/* Start/Calibrating/Completed button */}
  338. {calibrationCompleted ? (
  339. <div className="space-y-2">
  340. <button
  341. disabled
  342. className="w-full py-2.5 px-4 rounded-lg font-medium text-sm flex items-center justify-center gap-2 bg-bambu-green text-white cursor-default"
  343. >
  344. <Check className="w-4 h-4" />
  345. Completed
  346. </button>
  347. <button
  348. onClick={resetCalibration}
  349. className="w-full py-2 px-4 rounded-lg font-medium text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
  350. >
  351. New Calibration
  352. </button>
  353. </div>
  354. ) : (
  355. <button
  356. onClick={() => calibrationMutation.mutate()}
  357. disabled={!canStart}
  358. className={`w-full py-2.5 px-4 rounded-lg font-medium text-sm flex items-center justify-center gap-2 transition-colors ${
  359. isCalibrating
  360. ? 'bg-bambu-gray/50 text-white cursor-not-allowed'
  361. : canStart
  362. ? 'bg-bambu-green hover:bg-bambu-green/90 text-white'
  363. : 'bg-bambu-dark-tertiary text-bambu-gray cursor-not-allowed'
  364. }`}
  365. >
  366. {isCalibrating ? (
  367. <>
  368. <Loader2 className="w-4 h-4 animate-spin" />
  369. Calibrating
  370. </>
  371. ) : calibrationMutation.isPending ? (
  372. <>
  373. <Loader2 className="w-4 h-4 animate-spin" />
  374. Starting...
  375. </>
  376. ) : (
  377. 'Start Calibration'
  378. )}
  379. </button>
  380. )}
  381. {calibrationMutation.isError && (
  382. <div className="mt-2 text-xs text-red-400 text-center">
  383. {calibrationMutation.error?.message || 'Failed to start calibration'}
  384. </div>
  385. )}
  386. </div>
  387. </div>
  388. </div>
  389. </CardContent>
  390. </Card>
  391. </div>
  392. );
  393. }