mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
Minor bugfixes
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user