Explorar o código

Control page: new filament path wiring

Martin Ziegler hai 9 meses
pai
achega
863dedf6f4

+ 121 - 93
frontend/src/components/control/AMSSectionDual.tsx

@@ -39,6 +39,7 @@ interface AMSPanelContentProps {
   onSelectTray: (trayId: number | null) => void;
 }
 
+// Panel content - NO wiring, just slots and info
 function AMSPanelContent({
   units,
   side,
@@ -52,12 +53,12 @@ function AMSPanelContent({
   const slotPrefix = side === 'left' ? 'A' : 'B';
 
   return (
-    <div className="flex-1 min-w-0 overflow-visible">
+    <div className="flex-1 min-w-0">
       <div className="text-center text-[11px] font-semibold text-bambu-gray uppercase mb-2">
         {side === 'left' ? 'Left Nozzle' : 'Right Nozzle'}
       </div>
 
-      {/* AMS Tab Selectors - only show connected units */}
+      {/* AMS Tab Selectors */}
       <div className="flex gap-1.5 mb-2.5 p-1.5 bg-bambu-dark/50 rounded-lg w-fit">
         {units.map((unit, index) => (
           <button
@@ -86,7 +87,7 @@ function AMSPanelContent({
 
       {/* AMS Content */}
       {selectedUnit && (
-        <div className="bg-bambu-dark-secondary rounded-[10px] p-2.5 pb-0 overflow-visible">
+        <div className="bg-bambu-dark-secondary rounded-[10px] p-2.5">
           {/* AMS Header - Humidity & Temp */}
           <div className="flex items-center gap-2.5 text-xs text-bambu-gray mb-2">
             {selectedUnit.humidity !== null && (
@@ -117,8 +118,8 @@ function AMSPanelContent({
             ))}
           </div>
 
-          {/* AMS Slots with integrated wiring */}
-          <div className="flex justify-center gap-1.5 mb-0">
+          {/* AMS Slots - NO wiring here */}
+          <div className="flex justify-center gap-1.5">
             {selectedUnit.tray.map((tray) => {
               const globalTrayId = selectedUnit.id * 4 + tray.id;
               const isSelected = selectedTray === globalTrayId;
@@ -126,67 +127,42 @@ function AMSPanelContent({
               const isLight = isLightColor(tray.tray_color);
 
               return (
-                <div key={tray.id} className="flex flex-col items-center">
-                  <button
-                    onClick={() => !isEmpty && onSelectTray(isSelected ? null : globalTrayId)}
-                    disabled={isEmpty || isPrinting}
-                    className={`w-12 h-[70px] rounded-md border-2 overflow-hidden transition-all bg-bambu-dark ${
-                      isSelected
-                        ? 'border-[#d4a84b]'
-                        : 'border-bambu-dark-tertiary hover:border-bambu-gray'
-                    } ${isEmpty ? 'opacity-50' : ''} disabled:cursor-not-allowed`}
+                <button
+                  key={tray.id}
+                  onClick={() => !isEmpty && onSelectTray(isSelected ? null : globalTrayId)}
+                  disabled={isEmpty || isPrinting}
+                  className={`w-12 h-[70px] rounded-md border-2 overflow-hidden transition-all bg-bambu-dark ${
+                    isSelected
+                      ? 'border-[#d4a84b]'
+                      : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+                  } ${isEmpty ? 'opacity-50' : ''} disabled:cursor-not-allowed`}
+                >
+                  <div
+                    className="w-full h-full flex flex-col items-center justify-end pb-[5px]"
+                    style={{
+                      backgroundColor: isEmpty ? undefined : hexToRgb(tray.tray_color),
+                    }}
                   >
-                    <div
-                      className="w-full h-full flex flex-col items-center justify-end pb-[5px]"
-                      style={{
-                        backgroundColor: isEmpty ? undefined : hexToRgb(tray.tray_color),
-                      }}
+                    <span
+                      className={`text-[11px] font-semibold mb-1 ${
+                        isLight ? 'text-gray-800' : 'text-white'
+                      } ${isLight ? '' : 'drop-shadow-sm'}`}
                     >
-                      <span
-                        className={`text-[11px] font-semibold mb-1 ${
-                          isLight ? 'text-gray-800' : 'text-white'
-                        } ${isLight ? '' : 'drop-shadow-sm'}`}
-                      >
-                        {isEmpty ? '--' : tray.tray_type}
-                      </span>
-                      {!isEmpty && (
-                        <img
-                          src="/icons/eye.svg"
-                          alt=""
-                          className={`w-3.5 h-3.5 ${isLight ? '' : 'invert'}`}
-                          style={{ opacity: 0.8 }}
-                        />
-                      )}
-                    </div>
-                  </button>
-                  {/* Vertical wire from slot center down */}
-                  <div className="w-[2px] h-[14px] bg-[#909090]" />
-                </div>
+                      {isEmpty ? '--' : tray.tray_type}
+                    </span>
+                    {!isEmpty && (
+                      <img
+                        src="/icons/eye.svg"
+                        alt=""
+                        className={`w-3.5 h-3.5 ${isLight ? '' : 'invert'}`}
+                        style={{ opacity: 0.8 }}
+                      />
+                    )}
+                  </div>
+                </button>
               );
             })}
           </div>
-
-          {/* Wiring visualization - horizontal bar and hub */}
-          <div className="flex justify-center">
-            <div className="relative h-[50px]" style={{ width: '210px' }}>
-              {/* Horizontal bar connecting all slots (spans from first to last slot center) */}
-              <div className="absolute left-[24px] right-[24px] top-0 border-t-2 border-[#909090]" />
-
-            {/* Center hub box on the horizontal bar */}
-            <div className="absolute left-1/2 -translate-x-1/2 top-[-6px] w-[28px] h-[14px] bg-[#c0c0c0] border border-[#909090] rounded-sm" />
-
-            {/* Vertical wire from hub going down */}
-            <div className="absolute left-1/2 -translate-x-[1px] top-[8px] h-[14px] border-l-2 border-[#909090]" />
-
-            {/* Horizontal wire from hub toward the center of the panel (extends beyond panel edge) */}
-            {side === 'left' && (
-              <div className="absolute left-1/2 top-[21px] w-[calc(50%+30px)] border-t-2 border-[#909090]" />
-            )}
-            {side === 'right' && (
-              <div className="absolute right-1/2 top-[21px] w-[calc(50%+30px)] border-t-2 border-[#909090]" />
-            )}
-            </div>
-          </div>
         </div>
       )}
 
@@ -200,14 +176,88 @@ function AMSPanelContent({
   );
 }
 
+// Unified wiring layer - draws ALL wiring in one place
+interface WiringLayerProps {
+  isDualNozzle: boolean;
+}
+
+function WiringLayer({ isDualNozzle }: WiringLayerProps) {
+  if (!isDualNozzle) return null;
+
+  // All measurements relative to this container
+  // Container spans full width between panels
+  // Left panel wiring: slots → hub → down → right → down to extruder
+  // Right panel wiring: slots → hub → down → left → down to extruder
+
+  return (
+    <div className="relative w-full" style={{ height: '120px' }}>
+      {/* SVG for all wiring - single coordinate system */}
+      <svg
+        className="absolute inset-0 w-full h-full"
+        viewBox="0 0 600 120"
+        preserveAspectRatio="xMidYMid meet"
+      >
+        {/* Left panel wiring */}
+        {/* Vertical lines from 4 slots */}
+        <line x1="68" y1="0" x2="68" y2="14" stroke="#909090" strokeWidth="2" />
+        <line x1="122" y1="0" x2="122" y2="14" stroke="#909090" strokeWidth="2" />
+        <line x1="176" y1="0" x2="176" y2="14" stroke="#909090" strokeWidth="2" />
+        <line x1="230" y1="0" x2="230" y2="14" stroke="#909090" strokeWidth="2" />
+
+        {/* Horizontal bar connecting left slots */}
+        <line x1="68" y1="14" x2="230" y2="14" stroke="#909090" strokeWidth="2" />
+
+        {/* Left hub */}
+        <rect x="135" y="8" width="28" height="14" rx="2" fill="#c0c0c0" stroke="#909090" strokeWidth="1" />
+
+        {/* Vertical from left hub down */}
+        <line x1="149" y1="22" x2="149" y2="36" stroke="#909090" strokeWidth="2" />
+
+        {/* Horizontal from left hub toward center */}
+        <line x1="149" y1="36" x2="288" y2="36" stroke="#909090" strokeWidth="2" />
+
+        {/* Vertical down to left extruder inlet */}
+        <line x1="288" y1="36" x2="288" y2="85" stroke="#909090" strokeWidth="2" />
+
+        {/* Right panel wiring */}
+        {/* Vertical lines from 4 slots */}
+        <line x1="370" y1="0" x2="370" y2="14" stroke="#909090" strokeWidth="2" />
+        <line x1="424" y1="0" x2="424" y2="14" stroke="#909090" strokeWidth="2" />
+        <line x1="478" y1="0" x2="478" y2="14" stroke="#909090" strokeWidth="2" />
+        <line x1="532" y1="0" x2="532" y2="14" stroke="#909090" strokeWidth="2" />
+
+        {/* Horizontal bar connecting right slots */}
+        <line x1="370" y1="14" x2="532" y2="14" stroke="#909090" strokeWidth="2" />
+
+        {/* Right hub */}
+        <rect x="437" y="8" width="28" height="14" rx="2" fill="#c0c0c0" stroke="#909090" strokeWidth="1" />
+
+        {/* Vertical from right hub down */}
+        <line x1="451" y1="22" x2="451" y2="36" stroke="#909090" strokeWidth="2" />
+
+        {/* Horizontal from right hub toward center */}
+        <line x1="312" y1="36" x2="451" y2="36" stroke="#909090" strokeWidth="2" />
+
+        {/* Vertical down to right extruder inlet */}
+        <line x1="312" y1="36" x2="312" y2="85" stroke="#909090" strokeWidth="2" />
+      </svg>
+
+      {/* Extruder image - positioned at bottom center */}
+      <img
+        src="/icons/extruder-left-right.png"
+        alt="Extruder"
+        className="absolute left-1/2 -translate-x-1/2 bottom-0 h-[50px]"
+      />
+    </div>
+  );
+}
+
 export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDualProps) {
   const isConnected = status?.connected ?? false;
   const isPrinting = status?.state === 'RUNNING';
   const isDualNozzle = nozzleCount > 1;
   const amsUnits: AMSUnit[] = status?.ams ?? [];
 
-  // For dual nozzle, split AMS units between left and right
-  // In real implementation, this would be based on actual nozzle assignment
   const leftUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 0) : amsUnits;
   const rightUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 1) : [];
 
@@ -236,30 +286,9 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
   const isLoading = loadMutation.isPending || unloadMutation.isPending;
 
   return (
-    <div className="bg-bambu-dark-tertiary rounded-[10px] p-3 relative overflow-visible">
-      {/* Center wiring and Extruder - absolutely centered between the two AMS panels */}
-      {isDualNozzle && (
-        <>
-          {/* Center wiring: two vertical lines going down to extruder inlets */}
-          {/* Positioned to connect with horizontal wires from AMS panels */}
-          <div className="absolute left-1/2 -translate-x-1/2 bottom-[62px] pointer-events-none" style={{ width: '24px', height: '30px' }}>
-            {/* Left vertical line - connects to left AMS horizontal wire, goes to left extruder inlet */}
-            <div className="absolute left-0 top-0 h-full border-l-2 border-[#909090]" />
-            {/* Right vertical line - connects to right AMS horizontal wire, goes to right extruder inlet */}
-            <div className="absolute right-0 top-0 h-full border-l-2 border-[#909090]" />
-          </div>
-          {/* Extruder */}
-          <img
-            src="/icons/extruder-left-right.png"
-            alt="Extruder"
-            className="absolute h-[50px] left-1/2 -translate-x-1/2 bottom-[12px]"
-          />
-        </>
-      )}
-
-      {/* Dual Panel Layout */}
-      <div className="flex gap-5 overflow-visible">
-        {/* Left Nozzle Panel */}
+    <div className="bg-bambu-dark-tertiary rounded-[10px] p-3">
+      {/* Dual Panel Layout - just the panels, no wiring */}
+      <div className="flex gap-5">
         <AMSPanelContent
           units={leftUnits}
           side="left"
@@ -270,7 +299,6 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
           onSelectTray={setSelectedTray}
         />
 
-        {/* Right Nozzle Panel - only for dual nozzle */}
         {isDualNozzle && (
           <AMSPanelContent
             units={rightUnits}
@@ -284,9 +312,11 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
         )}
       </div>
 
-      {/* Action Buttons Row with Extruder */}
-      <div className="flex items-start pt-2">
-        {/* Left buttons */}
+      {/* Unified Wiring Layer - ALL wiring drawn here */}
+      <WiringLayer isDualNozzle={isDualNozzle} />
+
+      {/* Action Buttons Row - aligned with extruder */}
+      <div className="flex items-start -mt-[50px]">
         <div className="flex items-center gap-2">
           <button className="w-10 h-10 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark border border-bambu-dark-tertiary flex items-center justify-center">
             <img src="/icons/ams-settings.svg" alt="Settings" className="w-5 icon-theme" />
@@ -296,10 +326,8 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
           </button>
         </div>
 
-        {/* Spacer */}
         <div className="flex-1" />
 
-        {/* Right buttons */}
         <div className="flex items-center gap-2">
           <button
             onClick={handleUnload}

+ 101 - 63
frontend/src/components/control/ExtruderControls.tsx

@@ -1,7 +1,9 @@
 import { useState } from 'react';
 import { useMutation } from '@tanstack/react-query';
+import { api, isConfirmationRequired } from '../../api/client';
 import type { PrinterStatus } from '../../api/client';
 import { ChevronUp, ChevronDown } from 'lucide-react';
+import { ConfirmModal } from '../ConfirmModal';
 
 interface ExtruderControlsProps {
   printerId: number;
@@ -9,18 +11,34 @@ interface ExtruderControlsProps {
   nozzleCount: number;
 }
 
-export function ExtruderControls({ status, nozzleCount }: ExtruderControlsProps) {
+export function ExtruderControls({ printerId, status, nozzleCount }: ExtruderControlsProps) {
   const isConnected = status?.connected ?? false;
   const isPrinting = status?.state === 'RUNNING' || status?.state === 'PAUSE';
   const isDualNozzle = nozzleCount > 1;
 
   const [selectedNozzle, setSelectedNozzle] = useState<'left' | 'right'>('left');
+  const [confirmModal, setConfirmModal] = useState<{
+    token: string;
+    warning: string;
+    distance: number;
+  } | null>(null);
 
-  // TODO: Add extrude/retract API calls when available
   const extrudeMutation = useMutation({
-    mutationFn: async ({ distance }: { distance: number }) => {
-      // Placeholder - implement when API is ready
-      console.log(`Extrude ${distance}mm on ${selectedNozzle} nozzle`);
+    mutationFn: ({ distance, token }: { distance: number; token?: string }) => {
+      // G-code for extrusion: relative mode, extrude, back to absolute
+      // T0/T1 selects the tool for dual nozzle
+      const toolSelect = isDualNozzle ? `T${selectedNozzle === 'left' ? 0 : 1}\n` : '';
+      const gcode = `${toolSelect}G91\nG1 E${distance} F300\nG90`;
+      return api.sendGcode(printerId, gcode, token);
+    },
+    onSuccess: (result, variables) => {
+      if (isConfirmationRequired(result)) {
+        setConfirmModal({
+          token: result.token,
+          warning: result.warning,
+          distance: variables.distance,
+        });
+      }
     },
   });
 
@@ -28,71 +46,91 @@ export function ExtruderControls({ status, nozzleCount }: ExtruderControlsProps)
     extrudeMutation.mutate({ distance });
   };
 
+  const handleConfirm = () => {
+    if (confirmModal) {
+      extrudeMutation.mutate({ distance: confirmModal.distance, token: confirmModal.token });
+      setConfirmModal(null);
+    }
+  };
+
   const isDisabled = !isConnected || isPrinting || extrudeMutation.isPending;
 
   return (
-    <div className="flex flex-col items-center gap-1.5 justify-center">
-      {/* Left/Right Toggle - only for dual nozzle */}
-      {isDualNozzle && (
-        <div className="flex rounded-md overflow-hidden border border-bambu-dark-tertiary mb-1 flex-shrink-0">
-          <button
-            onClick={() => setSelectedNozzle('left')}
-            className={`px-3 py-1.5 text-sm border-r border-bambu-dark-tertiary transition-colors ${
-              selectedNozzle === 'left'
-                ? 'bg-bambu-green text-white'
-                : 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary'
-            }`}
-          >
-            Left
-          </button>
-          <button
-            onClick={() => setSelectedNozzle('right')}
-            className={`px-3 py-1.5 text-sm transition-colors ${
-              selectedNozzle === 'right'
-                ? 'bg-bambu-green text-white'
-                : 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary'
-            }`}
-          >
-            Right
-          </button>
+    <>
+      <div className="flex flex-col items-center gap-1.5 justify-center">
+        {/* Left/Right Toggle - only for dual nozzle */}
+        {isDualNozzle && (
+          <div className="flex rounded-md overflow-hidden border border-bambu-dark-tertiary mb-1 flex-shrink-0">
+            <button
+              onClick={() => setSelectedNozzle('left')}
+              className={`px-3 py-1.5 text-sm border-r border-bambu-dark-tertiary transition-colors ${
+                selectedNozzle === 'left'
+                  ? 'bg-bambu-green text-white'
+                  : 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary'
+              }`}
+            >
+              Left
+            </button>
+            <button
+              onClick={() => setSelectedNozzle('right')}
+              className={`px-3 py-1.5 text-sm transition-colors ${
+                selectedNozzle === 'right'
+                  ? 'bg-bambu-green text-white'
+                  : 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary'
+              }`}
+            >
+              Right
+            </button>
+          </div>
+        )}
+
+        {/* Extrude Up Button */}
+        <button
+          onClick={() => handleExtrude(5)}
+          disabled={isDisabled}
+          className="w-9 h-[30px] rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary flex items-center justify-center text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
+          title="Extrude 5mm"
+        >
+          <ChevronUp className="w-4 h-4" />
+        </button>
+
+        {/* Extruder Image */}
+        <div className="h-[120px] flex items-center justify-center">
+          <img
+            src={isDualNozzle ? "/icons/dual-extruder.png" : "/icons/single-extruder1.png"}
+            alt={isDualNozzle ? "Dual Extruder" : "Single Extruder"}
+            className="h-full object-contain"
+            onError={(e) => {
+              (e.target as HTMLImageElement).style.display = 'none';
+            }}
+          />
         </div>
-      )}
 
-      {/* Extrude Up Button */}
-      <button
-        onClick={() => handleExtrude(5)}
-        disabled={isDisabled}
-        className="w-9 h-[30px] rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary flex items-center justify-center text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
-        title="Extrude 5mm"
-      >
-        <ChevronUp className="w-4 h-4" />
-      </button>
+        {/* Retract Down Button */}
+        <button
+          onClick={() => handleExtrude(-5)}
+          disabled={isDisabled}
+          className="w-9 h-[30px] rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary flex items-center justify-center text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
+          title="Retract 5mm"
+        >
+          <ChevronDown className="w-4 h-4" />
+        </button>
 
-      {/* Extruder Image */}
-      <div className="h-[120px] flex items-center justify-center">
-        <img
-          src={isDualNozzle ? "/icons/dual-extruder.png" : "/icons/single-extruder1.png"}
-          alt={isDualNozzle ? "Dual Extruder" : "Single Extruder"}
-          className="h-full object-contain"
-          onError={(e) => {
-            // Fallback if image doesn't load
-            (e.target as HTMLImageElement).style.display = 'none';
-          }}
-        />
+        {/* Label */}
+        <span className="text-xs text-bambu-gray mt-0.5">Extruder</span>
       </div>
 
-      {/* Retract Down Button */}
-      <button
-        onClick={() => handleExtrude(-5)}
-        disabled={isDisabled}
-        className="w-9 h-[30px] rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary flex items-center justify-center text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
-        title="Retract 5mm"
-      >
-        <ChevronDown className="w-4 h-4" />
-      </button>
-
-      {/* Label */}
-      <span className="text-xs text-bambu-gray mt-0.5">Extruder</span>
-    </div>
+      {/* Confirmation Modal */}
+      {confirmModal && (
+        <ConfirmModal
+          title="Confirm Extrusion"
+          message={confirmModal.warning}
+          confirmText="Continue"
+          variant="warning"
+          onConfirm={handleConfirm}
+          onCancel={() => setConfirmModal(null)}
+        />
+      )}
+    </>
   );
 }

+ 109 - 14
frontend/src/components/control/TemperatureColumn.tsx

@@ -1,3 +1,6 @@
+import { useState } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '../../api/client';
 import type { PrinterStatus } from '../../api/client';
 
 interface Temperatures {
@@ -11,14 +14,102 @@ interface Temperatures {
 }
 
 interface TemperatureColumnProps {
-  printerId?: number;
+  printerId: number;
   status: PrinterStatus | null | undefined;
   nozzleCount: number;
 }
 
-export function TemperatureColumn({ status, nozzleCount }: TemperatureColumnProps) {
+type EditingField = 'nozzle' | 'nozzle_2' | 'bed' | null;
+
+export function TemperatureColumn({ printerId, status, nozzleCount }: TemperatureColumnProps) {
   const temps = (status?.temperatures ?? {}) as Temperatures;
   const isDualNozzle = nozzleCount > 1;
+  const isConnected = status?.connected ?? false;
+
+  const [editing, setEditing] = useState<EditingField>(null);
+  const [editValue, setEditValue] = useState('');
+
+  const bedMutation = useMutation({
+    mutationFn: (target: number) => api.setBedTemperature(printerId, target),
+  });
+
+  const nozzleMutation = useMutation({
+    mutationFn: ({ target, nozzle }: { target: number; nozzle: number }) =>
+      api.setNozzleTemperature(printerId, target, nozzle),
+  });
+
+  const lightMutation = useMutation({
+    mutationFn: (on: boolean) => api.setChamberLight(printerId, on),
+  });
+
+  const startEditing = (field: EditingField, currentValue: number) => {
+    if (!isConnected) return;
+    setEditing(field);
+    setEditValue(String(Math.round(currentValue)));
+  };
+
+  const cancelEditing = () => {
+    setEditing(null);
+    setEditValue('');
+  };
+
+  const submitEdit = () => {
+    const target = parseInt(editValue, 10);
+    if (isNaN(target) || target < 0) {
+      cancelEditing();
+      return;
+    }
+
+    if (editing === 'bed') {
+      bedMutation.mutate(target);
+    } else if (editing === 'nozzle') {
+      nozzleMutation.mutate({ target, nozzle: 0 });
+    } else if (editing === 'nozzle_2') {
+      nozzleMutation.mutate({ target, nozzle: 1 });
+    }
+    cancelEditing();
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === 'Enter') {
+      submitEdit();
+    } else if (e.key === 'Escape') {
+      cancelEditing();
+    }
+  };
+
+  const isDisabled = !isConnected;
+
+  const renderTargetTemp = (
+    field: EditingField,
+    targetValue: number
+  ) => {
+    if (editing === field) {
+      return (
+        <input
+          type="number"
+          value={editValue}
+          onChange={(e) => setEditValue(e.target.value)}
+          onBlur={submitEdit}
+          onKeyDown={handleKeyDown}
+          autoFocus
+          className="w-12 text-sm bg-bambu-dark border border-bambu-green rounded px-1 py-0.5 text-white text-center"
+          min={0}
+          max={350}
+        />
+      );
+    }
+    return (
+      <button
+        onClick={() => startEditing(field, targetValue)}
+        disabled={isDisabled}
+        className="text-sm text-bambu-gray hover:text-bambu-green disabled:hover:text-bambu-gray disabled:cursor-not-allowed"
+        title={isDisabled ? 'Printer not connected' : 'Click to set target temperature'}
+      >
+        /{Math.round(targetValue)} °C
+      </button>
+    );
+  };
 
   return (
     <div className="flex flex-col justify-evenly min-w-[150px] pr-5 border-r border-bambu-dark-tertiary">
@@ -33,7 +124,7 @@ export function TemperatureColumn({ status, nozzleCount }: TemperatureColumnProp
           </span>
         )}
         <span className="text-lg font-medium text-white">{Math.round(temps.nozzle ?? 0)}</span>
-        <span className="text-sm text-bambu-gray">/{Math.round(temps.nozzle_target ?? 0)} °C</span>
+        {renderTargetTemp('nozzle', temps.nozzle_target ?? 0)}
       </div>
 
       {/* Nozzle 2 (Right) - only for dual nozzle */}
@@ -46,7 +137,7 @@ export function TemperatureColumn({ status, nozzleCount }: TemperatureColumnProp
             R
           </span>
           <span className="text-lg font-medium text-white">{Math.round(temps.nozzle_2 ?? 0)}</span>
-          <span className="text-sm text-bambu-gray">/{Math.round(temps.nozzle_2_target ?? 0)} °C</span>
+          {renderTargetTemp('nozzle_2', temps.nozzle_2_target ?? 0)}
         </div>
       )}
 
@@ -55,38 +146,42 @@ export function TemperatureColumn({ status, nozzleCount }: TemperatureColumnProp
         <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
           <img src="/icons/heatbed.svg" alt="" className="w-5 icon-theme" />
         </div>
-        {/* Spacer to align with L/R badge (min-w-[18px]) */}
         {isDualNozzle && <span className="min-w-[18px] flex-shrink-0" />}
         <span className="text-lg font-medium text-white">{Math.round(temps.bed ?? 0)}</span>
-        <span className="text-sm text-bambu-gray">/{Math.round(temps.bed_target ?? 0)} °C</span>
+        {renderTargetTemp('bed', temps.bed_target ?? 0)}
       </div>
 
-      {/* Chamber */}
+      {/* Chamber - read only */}
       <div className="flex items-center gap-1.5">
         <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
           <img src="/icons/chamber.svg" alt="" className="w-5 icon-theme" />
         </div>
-        {/* Spacer to align with L/R badge */}
         {isDualNozzle && <span className="min-w-[18px] flex-shrink-0" />}
         <span className="text-lg font-medium text-white">{Math.round(temps.chamber ?? 0)}</span>
-        <span className="text-sm text-bambu-gray">/{0} °C</span>
+        <span className="text-sm text-bambu-gray">°C</span>
       </div>
 
       {/* Air Condition - button */}
-      <button className="flex items-center gap-2 hover:opacity-80 transition-opacity">
+      <button
+        disabled={isDisabled}
+        className="flex items-center gap-2 hover:opacity-80 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
+      >
         <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
           <img src="/icons/ventilation.svg" alt="" className="w-5 icon-theme" />
         </div>
         <span className="text-sm text-bambu-gray">Air Condition</span>
       </button>
 
-      {/* Lamp - button */}
-      <button className="flex items-center gap-2 hover:opacity-80 transition-opacity">
+      {/* Lamp - button (toggle, state not tracked in status yet) */}
+      <button
+        onClick={() => lightMutation.mutate(true)}
+        disabled={isDisabled || lightMutation.isPending}
+        className="flex items-center gap-2 hover:opacity-80 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
+      >
         <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
-          <img src="/icons/ventilation.svg" alt="" className="w-4 icon-theme" />
+          <img src="/icons/lamp.svg" alt="" className="w-4 icon-theme" />
         </div>
         <span className="text-sm text-bambu-gray">Lamp</span>
-        <div className="w-3.5 h-3.5 rounded-full bg-bambu-green" />
       </button>
     </div>
   );

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BzyUcjxL.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-Da3EoG5B.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-Dg2N9nGX.js


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-BRHFyg0M.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BzyUcjxL.css">
+    <script type="module" crossorigin src="/assets/index-Dg2N9nGX.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Da3EoG5B.css">
   </head>
   <body>
     <div id="root"></div>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio