Browse Source

Minor bugfixes

Martin Ziegler 6 months ago
parent
commit
3031c2ae3e

+ 13 - 0
backend/app/api/routes/archives.py

@@ -208,6 +208,17 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
         for printer_key, accs in printer_accuracies.items():
             accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
 
+    # Energy totals
+    energy_kwh_result = await db.execute(
+        select(func.sum(PrintArchive.energy_kwh))
+    )
+    total_energy_kwh = energy_kwh_result.scalar() or 0
+
+    energy_cost_result = await db.execute(
+        select(func.sum(PrintArchive.energy_cost))
+    )
+    total_energy_cost = energy_cost_result.scalar() or 0
+
     return ArchiveStats(
         total_prints=total_prints,
         successful_prints=successful_prints,
@@ -219,6 +230,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
         prints_by_printer=prints_by_printer,
         average_time_accuracy=average_accuracy,
         time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
+        total_energy_kwh=round(total_energy_kwh, 3),
+        total_energy_cost=round(total_energy_cost, 2),
     )
 
 

+ 1 - 1
backend/app/api/routes/settings.py

@@ -48,7 +48,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
             # Parse the value based on the expected type
             if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo"]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
-            elif setting.key == "default_filament_cost":
+            elif setting.key in ["default_filament_cost", "energy_cost_per_kwh"]:
                 settings_dict[setting.key] = float(setting.value)
             else:
                 settings_dict[setting.key] = setting.value

+ 10 - 1
backend/app/api/routes/smart_plugs.py

@@ -17,6 +17,7 @@ from backend.app.schemas.smart_plug import (
     SmartPlugControl,
     SmartPlugStatus,
     SmartPlugTestConnection,
+    SmartPlugEnergy,
 )
 from backend.app.services.tasmota import tasmota_service
 
@@ -183,7 +184,7 @@ async def control_smart_plug(
 
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
-    """Get current plug status from device."""
+    """Get current plug status from device including energy data."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     if not plug:
@@ -197,10 +198,18 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
         plug.last_checked = datetime.utcnow()
         await db.commit()
 
+    # Fetch energy data if device is reachable
+    energy_data = None
+    if status["reachable"]:
+        energy = await tasmota_service.get_energy(plug)
+        if energy:
+            energy_data = SmartPlugEnergy(**energy)
+
     return SmartPlugStatus(
         state=status["state"],
         reachable=status["reachable"],
         device_name=status.get("device_name"),
+        energy=energy_data,
     )
 
 

+ 57 - 0
backend/app/main.py

@@ -28,11 +28,16 @@ from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.archive import ArchiveService
 from backend.app.services.bambu_ftp import download_file_async
 from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.services.tasmota import tasmota_service
+from backend.app.models.smart_plug import SmartPlug
 
 
 # Track active prints: {(printer_id, filename): archive_id}
 _active_prints: dict[tuple[int, str], int] = {}
 
+# Track starting energy for prints: {archive_id: starting_kwh}
+_print_energy_start: dict[int, float] = {}
+
 
 async def on_printer_status_change(printer_id: int, state: PrinterState):
     """Handle printer status changes - broadcast via WebSocket."""
@@ -183,6 +188,20 @@ async def on_print_start(printer_id: int, data: dict):
 
                 logger.info(f"Created archive {archive.id} for {downloaded_filename}")
 
+                # Record starting energy from smart plug if available
+                try:
+                    plug_result = await db.execute(
+                        select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+                    )
+                    plug = plug_result.scalar_one_or_none()
+                    if plug:
+                        energy = await tasmota_service.get_energy(plug)
+                        if energy and energy.get("total") is not None:
+                            _print_energy_start[archive.id] = energy["total"]
+                            logger.info(f"Recorded starting energy for archive {archive.id}: {energy['total']} kWh")
+                except Exception as e:
+                    logger.warning(f"Failed to record starting energy: {e}")
+
                 await ws_manager.send_archive_created({
                     "id": archive.id,
                     "printer_id": archive.printer_id,
@@ -276,6 +295,44 @@ async def on_print_complete(printer_id: int, data: dict):
             "status": status,
         })
 
+    # Calculate energy used for this print
+    try:
+        starting_kwh = _print_energy_start.pop(archive_id, None)
+        if starting_kwh is not None:
+            async with async_session() as db:
+                # Get smart plug for this printer
+                plug_result = await db.execute(
+                    select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+                )
+                plug = plug_result.scalar_one_or_none()
+
+                if plug:
+                    energy = await tasmota_service.get_energy(plug)
+                    if energy and energy.get("total") is not None:
+                        ending_kwh = energy["total"]
+                        energy_used = round(ending_kwh - starting_kwh, 4)
+
+                        # Get energy cost per kWh from settings (default to 0.15)
+                        from backend.app.api.routes.settings import get_setting
+                        energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
+                        cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
+                        energy_cost = round(energy_used * cost_per_kwh, 2)
+
+                        # Update archive with energy data
+                        from backend.app.models.archive import PrintArchive
+                        result = await db.execute(
+                            select(PrintArchive).where(PrintArchive.id == archive_id)
+                        )
+                        archive = result.scalar_one_or_none()
+                        if archive:
+                            archive.energy_kwh = energy_used
+                            archive.energy_cost = energy_cost
+                            await db.commit()
+                            logger.info(f"Recorded energy for archive {archive_id}: {energy_used} kWh (${energy_cost})")
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Failed to calculate energy: {e}")
+
     # Capture finish photo from printer camera
     try:
         async with async_session() as db:

+ 4 - 0
backend/app/models/archive.py

@@ -50,6 +50,10 @@ class PrintArchive(Base):
     photos: Mapped[list | None] = mapped_column(JSON)  # List of photo filenames
     failure_reason: Mapped[str | None] = mapped_column(String(100))  # For failed prints
 
+    # Energy tracking
+    energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh
+    energy_cost: Mapped[float | None] = mapped_column(Float)  # Cost of energy consumed
+
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(
         DateTime, server_default=func.now()

+ 7 - 0
backend/app/schemas/archive.py

@@ -65,6 +65,10 @@ class ArchiveResponse(BaseModel):
     photos: list | None
     failure_reason: str | None
 
+    # Energy tracking
+    energy_kwh: float | None = None
+    energy_cost: float | None = None
+
     created_at: datetime
 
     class Config:
@@ -83,6 +87,9 @@ class ArchiveStats(BaseModel):
     # Time accuracy stats
     average_time_accuracy: float | None = None  # Average across all prints with data
     time_accuracy_by_printer: dict | None = None  # Per-printer accuracy
+    # Energy stats
+    total_energy_kwh: float = 0.0
+    total_energy_cost: float = 0.0
 
 
 class ProjectPageImage(BaseModel):

+ 2 - 0
backend/app/schemas/settings.py

@@ -9,6 +9,7 @@ class AppSettings(BaseModel):
     capture_finish_photo: bool = Field(default=True, description="Capture photo from printer camera when print completes")
     default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
     currency: str = Field(default="USD", description="Currency for cost tracking")
+    energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
 
 
 class AppSettingsUpdate(BaseModel):
@@ -19,3 +20,4 @@ class AppSettingsUpdate(BaseModel):
     capture_finish_photo: bool | None = None
     default_filament_cost: float | None = None
     currency: str | None = None
+    energy_cost_per_kwh: float | None = None

+ 14 - 0
backend/app/schemas/smart_plug.py

@@ -50,10 +50,24 @@ class SmartPlugControl(BaseModel):
     action: Literal["on", "off", "toggle"]
 
 
+class SmartPlugEnergy(BaseModel):
+    """Energy monitoring data from a smart plug."""
+    power: float | None = None  # Current watts
+    voltage: float | None = None  # Volts
+    current: float | None = None  # Amps
+    today: float | None = None  # kWh used today
+    yesterday: float | None = None  # kWh used yesterday
+    total: float | None = None  # Total kWh
+    factor: float | None = None  # Power factor (0-1)
+    apparent_power: float | None = None  # VA
+    reactive_power: float | None = None  # VAr
+
+
 class SmartPlugStatus(BaseModel):
     state: str | None = None  # "ON", "OFF", or None if unreachable
     reachable: bool = True
     device_name: str | None = None
+    energy: SmartPlugEnergy | None = None  # Energy data if available
 
 
 class SmartPlugTestConnection(BaseModel):

+ 38 - 0
backend/app/services/tasmota.py

@@ -150,6 +150,44 @@ class TasmotaService:
 
         return success
 
+    async def get_energy(self, plug: "SmartPlug") -> dict | None:
+        """Get energy monitoring data from the plug.
+
+        Returns dict with energy data or None if not available:
+            - power: Current power in watts
+            - voltage: Voltage in V
+            - current: Current in A
+            - today: Energy used today in kWh
+            - total: Total energy in kWh
+            - factor: Power factor (0-1)
+        """
+        result = await self._send_command(
+            plug.ip_address, "Status 8", plug.username, plug.password
+        )
+
+        if result is None:
+            return None
+
+        # Response format: {"StatusSNS":{"ENERGY":{...}}}
+        status_sns = result.get("StatusSNS", {})
+        energy = status_sns.get("ENERGY")
+
+        if not energy:
+            # Device doesn't have energy monitoring
+            return None
+
+        return {
+            "power": energy.get("Power"),  # Current watts
+            "voltage": energy.get("Voltage"),  # Volts
+            "current": energy.get("Current"),  # Amps
+            "today": energy.get("Today"),  # kWh today
+            "yesterday": energy.get("Yesterday"),  # kWh yesterday
+            "total": energy.get("Total"),  # Total kWh
+            "factor": energy.get("Factor"),  # Power factor
+            "apparent_power": energy.get("ApparentPower"),  # VA
+            "reactive_power": energy.get("ReactivePower"),  # VAr
+        }
+
     async def test_connection(
         self,
         ip: str,

+ 18 - 0
frontend/src/api/client.ts

@@ -114,6 +114,8 @@ export interface Archive {
   cost: number | null;
   photos: string[] | null;
   failure_reason: string | null;
+  energy_kwh: number | null;
+  energy_cost: number | null;
   created_at: string;
 }
 
@@ -128,6 +130,8 @@ export interface ArchiveStats {
   prints_by_printer: Record<string, number>;
   average_time_accuracy: number | null;
   time_accuracy_by_printer: Record<string, number> | null;
+  total_energy_kwh: number;
+  total_energy_cost: number;
 }
 
 export interface BulkUploadResult {
@@ -144,6 +148,7 @@ export interface AppSettings {
   capture_finish_photo: boolean;
   default_filament_cost: number;
   currency: string;
+  energy_cost_per_kwh: number;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -231,10 +236,23 @@ export interface SmartPlugUpdate {
   password?: string | null;
 }
 
+export interface SmartPlugEnergy {
+  power: number | null;  // Current watts
+  voltage: number | null;  // Volts
+  current: number | null;  // Amps
+  today: number | null;  // kWh used today
+  yesterday: number | null;  // kWh used yesterday
+  total: number | null;  // Total kWh
+  factor: number | null;  // Power factor (0-1)
+  apparent_power: number | null;  // VA
+  reactive_power: number | null;  // VAr
+}
+
 export interface SmartPlugStatus {
   state: string | null;
   reachable: boolean;
   device_name: string | null;
+  energy: SmartPlugEnergy | null;
 }
 
 export interface SmartPlugTestResult {

+ 7 - 4
frontend/src/pages/ArchivesPage.tsx

@@ -246,14 +246,15 @@ function ArchiveCard({
 
   return (
     <Card
-      className={`relative flex flex-col ${isSelected ? 'ring-2 ring-bambu-green' : ''}`}
+      className={`relative flex flex-col ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
       onContextMenu={handleContextMenu}
+      onClick={selectionMode ? () => onSelect(archive.id) : undefined}
     >
       {/* Selection checkbox */}
       {selectionMode && (
         <button
           className="absolute top-2 left-2 z-10 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
-          onClick={() => onSelect(archive.id)}
+          onClick={(e) => { e.stopPropagation(); onSelect(archive.id); }}
         >
           {isSelected ? (
             <CheckSquare className="w-5 h-5 text-bambu-green" />
@@ -643,6 +644,7 @@ export function ArchivesPage() {
   const [uploadFiles, setUploadFiles] = useState<File[]>([]);
   const [isDraggingOver, setIsDraggingOver] = useState(false);
   const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  const [isSelectionMode, setIsSelectionMode] = useState(false);
   const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
   const [showBatchTag, setShowBatchTag] = useState(false);
   const [viewMode, setViewMode] = useState<ViewMode>('grid');
@@ -759,7 +761,7 @@ export function ArchivesPage() {
       }
     });
 
-  const selectionMode = selectedIds.size > 0;
+  const selectionMode = isSelectionMode || selectedIds.size > 0;
 
   const toggleSelect = (id: number) => {
     setSelectedIds((prev) => {
@@ -781,6 +783,7 @@ export function ArchivesPage() {
 
   const clearSelection = () => {
     setSelectedIds(new Set());
+    setIsSelectionMode(false);
   };
 
   const toggleColor = (color: string) => {
@@ -966,7 +969,7 @@ export function ArchivesPage() {
         </div>
         <div className="flex items-center gap-3">
           {!selectionMode && (
-            <Button variant="secondary" onClick={() => filteredArchives?.length && toggleSelect(filteredArchives[0].id)}>
+            <Button variant="secondary" onClick={() => setIsSelectionMode(true)}>
               <CheckSquare className="w-4 h-4" />
               Select
             </Button>

+ 8 - 2
frontend/src/pages/PrintersPage.tsx

@@ -103,12 +103,12 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
     queryFn: () => api.getSmartPlugByPrinter(printer.id),
   });
 
-  // Fetch smart plug status if plug exists
+  // Fetch smart plug status if plug exists (faster refresh for energy monitoring)
   const { data: plugStatus } = useQuery({
     queryKey: ['smartPlugStatus', smartPlug?.id],
     queryFn: () => smartPlug ? api.getSmartPlugStatus(smartPlug.id) : null,
     enabled: !!smartPlug,
-    refetchInterval: 30000,
+    refetchInterval: 10000, // 10 seconds for real-time power display
   });
 
   // Determine if this card should be hidden
@@ -366,6 +366,12 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
                     {plugStatus.state || '?'}
                   </span>
                 )}
+                {/* Power consumption display */}
+                {plugStatus?.energy?.power != null && plugStatus.state === 'ON' && (
+                  <span className="text-xs text-yellow-400 font-medium flex-shrink-0">
+                    {plugStatus.energy.power}W
+                  </span>
+                )}
               </div>
 
               {/* Spacer */}

+ 20 - 1
frontend/src/pages/SettingsPage.tsx

@@ -46,7 +46,8 @@ export function SettingsPage() {
         settings.save_thumbnails !== localSettings.save_thumbnails ||
         settings.capture_finish_photo !== localSettings.capture_finish_photo ||
         settings.default_filament_cost !== localSettings.default_filament_cost ||
-        settings.currency !== localSettings.currency;
+        settings.currency !== localSettings.currency ||
+        settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh;
       setHasChanges(changed);
     }
   }, [settings, localSettings]);
@@ -222,6 +223,24 @@ export function SettingsPage() {
                   <option value="AUD">AUD ($)</option>
                 </select>
               </div>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Electricity cost per kWh
+                </label>
+                <input
+                  type="number"
+                  step="0.01"
+                  min="0"
+                  value={localSettings.energy_cost_per_kwh}
+                  onChange={(e) =>
+                    updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0)
+                  }
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                />
+                <p className="text-xs text-bambu-gray mt-1">
+                  Used for tracking energy costs per print via smart plugs
+                </p>
+              </div>
             </CardContent>
           </Card>
 

+ 23 - 2
frontend/src/pages/StatsPage.tsx

@@ -7,6 +7,7 @@ import {
   DollarSign,
   Printer,
   Target,
+  Zap,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
@@ -25,11 +26,13 @@ function QuickStatsWidget({
     total_print_time_hours: number;
     total_filament_grams: number;
     total_cost: number;
+    total_energy_kwh: number;
+    total_energy_cost: number;
   } | undefined;
   currency: string;
 }) {
   return (
-    <div className="grid grid-cols-2 gap-4">
+    <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
       <div className="flex items-start gap-3">
         <div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
           <Package className="w-5 h-5" />
@@ -62,10 +65,28 @@ function QuickStatsWidget({
           <DollarSign className="w-5 h-5" />
         </div>
         <div>
-          <p className="text-xs text-bambu-gray">Total Cost</p>
+          <p className="text-xs text-bambu-gray">Filament Cost</p>
           <p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
         </div>
       </div>
+      <div className="flex items-start gap-3">
+        <div className="p-2 rounded-lg bg-bambu-dark text-yellow-400">
+          <Zap className="w-5 h-5" />
+        </div>
+        <div>
+          <p className="text-xs text-bambu-gray">Energy Used</p>
+          <p className="text-xl font-bold text-white">{stats?.total_energy_kwh.toFixed(2) || '0.00'} kWh</p>
+        </div>
+      </div>
+      <div className="flex items-start gap-3">
+        <div className="p-2 rounded-lg bg-bambu-dark text-yellow-500">
+          <DollarSign className="w-5 h-5" />
+        </div>
+        <div>
+          <p className="text-xs text-bambu-gray">Energy Cost</p>
+          <p className="text-xl font-bold text-white">{currency} {stats?.total_energy_cost.toFixed(2) || '0.00'}</p>
+        </div>
+      </div>
     </div>
   );
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-C2KAUV7t.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DFJpXKHm.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-8ml33qQA.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BKGWUT04.css">
+    <script type="module" crossorigin src="/assets/index-DFJpXKHm.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C2KAUV7t.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff