diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index bc67c8ce9..e8f891284 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -18,6 +18,7 @@ from backend.app.schemas.printer import ( PrinterUpdate, PrinterResponse, PrinterStatus, + HMSErrorResponse, ) from backend.app.services.printer_manager import printer_manager from backend.app.services.bambu_ftp import ( @@ -138,6 +139,12 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) if state.state == "RUNNING" and state.gcode_file: cover_url = f"/api/v1/printers/{printer_id}/cover" + # Convert HMS errors to response format + hms_errors = [ + HMSErrorResponse(code=e.code, module=e.module, severity=e.severity) + for e in (state.hms_errors or []) + ] + return PrinterStatus( id=printer_id, name=printer.name, @@ -152,6 +159,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) total_layers=state.total_layers, temperatures=state.temperatures, cover_url=cover_url, + hms_errors=hms_errors, ) @@ -410,3 +418,72 @@ async def get_printer_storage( storage_info = await get_storage_info_async(printer.ip_address, printer.access_code) return storage_info or {"used_bytes": None, "free_bytes": None} + + +# ============================================ +# MQTT Debug Logging Endpoints +# ============================================ + +@router.post("/{printer_id}/logging/enable") +async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)): + """Enable MQTT message logging for a printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + success = printer_manager.enable_logging(printer_id, True) + if not success: + raise HTTPException(400, "Printer not connected") + + return {"logging_enabled": True} + + +@router.post("/{printer_id}/logging/disable") +async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)): + """Disable MQTT message logging for a printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + success = printer_manager.enable_logging(printer_id, False) + if not success: + raise HTTPException(400, "Printer not connected") + + return {"logging_enabled": False} + + +@router.get("/{printer_id}/logging") +async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)): + """Get MQTT message logs for a printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + logs = printer_manager.get_logs(printer_id) + return { + "logging_enabled": printer_manager.is_logging_enabled(printer_id), + "logs": [ + { + "timestamp": log.timestamp, + "topic": log.topic, + "direction": log.direction, + "payload": log.payload, + } + for log in logs + ], + } + + +@router.delete("/{printer_id}/logging") +async def clear_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)): + """Clear MQTT message logs for a printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + printer_manager.clear_logs(printer_id) + return {"status": "cleared"} diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 1d47ae5c9..ae720d8c3 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -1,4 +1,3 @@ -import json from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index d572f13f2..5c545cc71 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -34,6 +34,12 @@ class PrinterResponse(PrinterBase): from_attributes = True +class HMSErrorResponse(BaseModel): + code: str + module: int + severity: int # 1=fatal, 2=serious, 3=common, 4=info + + class PrinterStatus(BaseModel): id: int name: str @@ -48,3 +54,4 @@ class PrinterStatus(BaseModel): total_layers: int | None = None temperatures: dict | None = None cover_url: str | None = None + hms_errors: list[HMSErrorResponse] = [] diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index be5f2af8a..69aca4da4 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -1,12 +1,32 @@ import json import ssl import asyncio +from collections import deque +from datetime import datetime from typing import Callable from dataclasses import dataclass, field import paho.mqtt.client as mqtt +@dataclass +class MQTTLogEntry: + """Log entry for MQTT message debugging.""" + timestamp: str + topic: str + direction: str # "in" or "out" + payload: dict + + +@dataclass +class HMSError: + """Health Management System error from printer.""" + code: str + module: int + severity: int # 1=fatal, 2=serious, 3=common, 4=info + message: str = "" + + @dataclass class PrinterState: connected: bool = False @@ -21,6 +41,7 @@ class PrinterState: raw_data: dict = field(default_factory=dict) gcode_file: str | None = None subtask_id: str | None = None + hms_errors: list = field(default_factory=list) # List of HMSError class BambuMQTTClient: @@ -49,6 +70,8 @@ class BambuMQTTClient: self._loop: asyncio.AbstractEventLoop | None = None self._previous_gcode_state: str | None = None self._previous_gcode_file: str | None = None + self._message_log: deque[MQTTLogEntry] = deque(maxlen=100) + self._logging_enabled: bool = False @property def topic_subscribe(self) -> str: @@ -75,6 +98,14 @@ class BambuMQTTClient: def _on_message(self, client, userdata, msg): try: payload = json.loads(msg.payload.decode()) + # Log message if logging is enabled + if self._logging_enabled: + self._message_log.append(MQTTLogEntry( + timestamp=datetime.now().isoformat(), + topic=msg.topic, + direction="in", + payload=payload, + )) self._process_message(payload) except json.JSONDecodeError: pass @@ -131,6 +162,32 @@ class BambuMQTTClient: if temps: self.state.temperatures = temps + # Parse HMS (Health Management System) errors + if "hms" in data: + hms_list = data["hms"] + self.state.hms_errors = [] + if isinstance(hms_list, list): + for hms in hms_list: + if isinstance(hms, dict): + # HMS format: {"attr": code, "code": full_code} + # The code is a hex string, severity is in bits + code = hms.get("code", hms.get("attr", "0")) + if isinstance(code, int): + code = hex(code) + # Parse severity from code (typically last 4 bits indicate level) + try: + code_int = int(str(code).replace("0x", ""), 16) if code else 0 + severity = (code_int >> 16) & 0xF # Extract severity bits + module = (code_int >> 24) & 0xFF # Extract module bits + except (ValueError, TypeError): + severity = 3 + module = 0 + self.state.hms_errors.append(HMSError( + code=str(code), + module=module, + severity=severity if severity > 0 else 3, + )) + self.state.raw_data = data # Detect print start (state changes TO RUNNING with a file) @@ -236,4 +293,31 @@ class BambuMQTTClient: def send_command(self, command: dict): """Send a command to the printer.""" if self._client and self.state.connected: + # Log outgoing message if logging is enabled + if self._logging_enabled: + self._message_log.append(MQTTLogEntry( + timestamp=datetime.now().isoformat(), + topic=self.topic_publish, + direction="out", + payload=command, + )) self._client.publish(self.topic_publish, json.dumps(command)) + + def enable_logging(self, enabled: bool = True): + """Enable or disable MQTT message logging.""" + self._logging_enabled = enabled + if not enabled: + self._message_log.clear() + + def get_logs(self) -> list[MQTTLogEntry]: + """Get all logged MQTT messages.""" + return list(self._message_log) + + def clear_logs(self): + """Clear the message log.""" + self._message_log.clear() + + @property + def logging_enabled(self) -> bool: + """Check if logging is enabled.""" + return self._logging_enabled diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 3d629501f..e2cf7073e 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from backend.app.models.printer import Printer -from backend.app.services.bambu_mqtt import BambuMQTTClient, PrinterState +from backend.app.services.bambu_mqtt import BambuMQTTClient, PrinterState, MQTTLogEntry from backend.app.services.bambu_ftp import BambuFTPClient @@ -118,6 +118,32 @@ class PrinterManager: return self._clients[printer_id].start_print(filename) return False + def enable_logging(self, printer_id: int, enabled: bool = True) -> bool: + """Enable or disable MQTT logging for a printer.""" + if printer_id in self._clients: + self._clients[printer_id].enable_logging(enabled) + return True + return False + + def get_logs(self, printer_id: int) -> list[MQTTLogEntry]: + """Get MQTT logs for a printer.""" + if printer_id in self._clients: + return self._clients[printer_id].get_logs() + return [] + + def clear_logs(self, printer_id: int) -> bool: + """Clear MQTT logs for a printer.""" + if printer_id in self._clients: + self._clients[printer_id].clear_logs() + return True + return False + + def is_logging_enabled(self, printer_id: int) -> bool: + """Check if logging is enabled for a printer.""" + if printer_id in self._clients: + return self._clients[printer_id].logging_enabled + return False + async def test_connection( self, ip_address: str, @@ -159,6 +185,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> "layer_num": state.layer_num, "total_layers": state.total_layers, "temperatures": state.temperatures, + "hms_errors": [ + {"code": e.code, "module": e.module, "severity": e.severity} + for e in (state.hms_errors or []) + ], } # Add cover URL if there's an active print and printer_id is provided if printer_id and state.state == "RUNNING" and state.gcode_file: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2017b66c5..61649bcfa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -34,6 +34,12 @@ export interface Printer { updated_at: string; } +export interface HMSError { + code: string; + module: number; + severity: number; // 1=fatal, 2=serious, 3=common, 4=info +} + export interface PrinterStatus { id: number; name: string; @@ -54,6 +60,7 @@ export interface PrinterStatus { chamber?: number; } | null; cover_url: string | null; + hms_errors: HMSError[]; } export interface PrinterCreate { @@ -235,6 +242,19 @@ export interface SmartPlugTestResult { device_name: string | null; } +// MQTT Logging types +export interface MQTTLogEntry { + timestamp: string; + topic: string; + direction: 'in' | 'out'; + payload: Record; +} + +export interface MQTTLogsResponse { + logging_enabled: boolean; + logs: MQTTLogEntry[]; +} + // API functions export const api = { // Printers @@ -263,6 +283,22 @@ export const api = { method: 'POST', }), + // MQTT Debug Logging + enableMQTTLogging: (printerId: number) => + request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, { + method: 'POST', + }), + disableMQTTLogging: (printerId: number) => + request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/disable`, { + method: 'POST', + }), + getMQTTLogs: (printerId: number) => + request(`/printers/${printerId}/logging`), + clearMQTTLogs: (printerId: number) => + request<{ status: string }>(`/printers/${printerId}/logging`, { + method: 'DELETE', + }), + // Printer File Manager getPrinterFiles: (printerId: number, path = '/') => request<{ @@ -507,4 +543,5 @@ export const api = { method: 'POST', body: JSON.stringify({ ip_address, username, password }), }), + }; diff --git a/frontend/src/components/MQTTDebugModal.tsx b/frontend/src/components/MQTTDebugModal.tsx new file mode 100644 index 000000000..be879d588 --- /dev/null +++ b/frontend/src/components/MQTTDebugModal.tsx @@ -0,0 +1,229 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp } from 'lucide-react'; +import { api, type MQTTLogEntry } from '../api/client'; +import { Button } from './Button'; +import { useState, useEffect, useRef } from 'react'; + +interface MQTTDebugModalProps { + printerId: number; + printerName: string; + onClose: () => void; +} + +export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugModalProps) { + const queryClient = useQueryClient(); + const [autoScroll, setAutoScroll] = useState(true); + const [expandedLogs, setExpandedLogs] = useState>(new Set()); + const logContainerRef = useRef(null); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ['mqtt-logs', printerId], + queryFn: () => api.getMQTTLogs(printerId), + refetchInterval: 1000, // Poll every second when logging is enabled + }); + + const enableMutation = useMutation({ + mutationFn: () => api.enableMQTTLogging(printerId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] }); + }, + }); + + const disableMutation = useMutation({ + mutationFn: () => api.disableMQTTLogging(printerId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] }); + }, + }); + + const clearMutation = useMutation({ + mutationFn: () => api.clearMQTTLogs(printerId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] }); + }, + }); + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + if (autoScroll && logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [data?.logs, autoScroll]); + + const toggleExpand = (index: number) => { + setExpandedLogs((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 3 }); + }; + + const formatPayload = (payload: Record, expanded: boolean) => { + const json = JSON.stringify(payload, null, expanded ? 2 : 0); + if (!expanded && json.length > 100) { + return json.substring(0, 100) + '...'; + } + return json; + }; + + const loggingEnabled = data?.logging_enabled ?? false; + const logs = data?.logs ?? []; + + return ( +
+
+ {/* Header */} +
+
+

MQTT Debug Log

+

{printerName}

+
+ +
+ + {/* Controls */} +
+ {loggingEnabled ? ( + + ) : ( + + )} + + +
+ + + {logs.length} message{logs.length !== 1 ? 's' : ''} + +
+ + {/* Log Content */} +
+ {logs.length === 0 ? ( +
+

No messages logged yet

+ {!loggingEnabled && ( +

Click "Start Logging" to begin capturing MQTT messages

+ )} +
+ ) : ( +
+ {logs.map((log: MQTTLogEntry, index: number) => { + const isExpanded = expandedLogs.has(index); + const isIncoming = log.direction === 'in'; + + return ( +
toggleExpand(index)} + > +
+ + {formatTimestamp(log.timestamp)} + + + {isIncoming ? ( + + ) : ( + + )} + + {log.topic} +
+
+                      {formatPayload(log.payload, isExpanded)}
+                    
+
+ ); + })} +
+ )} +
+ + {/* Footer */} +
+
+ {loggingEnabled ? ( + + + Logging active - messages will auto-refresh + + ) : ( + Logging stopped + )} +
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 3ee782fa8..92c80ac1e 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -11,6 +11,8 @@ import { RefreshCw, Box, HardDrive, + AlertTriangle, + Terminal, } from 'lucide-react'; import { api } from '../api/client'; import type { Printer, PrinterCreate } from '../api/client'; @@ -18,6 +20,7 @@ import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; import { FileManagerModal } from '../components/FileManagerModal'; +import { MQTTDebugModal } from '../components/MQTTDebugModal'; function formatTime(seconds: number): string { const hours = Math.floor(seconds / 3600); @@ -79,6 +82,7 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf const [showMenu, setShowMenu] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showFileManager, setShowFileManager] = useState(false); + const [showMQTTDebug, setShowMQTTDebug] = useState(false); const { data: status } = useQuery({ queryKey: ['printerStatus', printer.id], @@ -131,6 +135,28 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf )} {status?.connected ? 'Connected' : 'Offline'} + {/* HMS Status Indicator */} + {status?.connected && ( + 0 + ? status.hms_errors.some(e => e.severity <= 2) + ? 'bg-red-500/20 text-red-400' + : 'bg-orange-500/20 text-orange-400' + : 'bg-bambu-green/20 text-bambu-green' + }`} + title={ + status.hms_errors && status.hms_errors.length > 0 + ? `${status.hms_errors.length} HMS error(s)` + : 'No HMS errors' + } + > + + {status.hms_errors && status.hms_errors.length > 0 + ? status.hms_errors.length + : 'OK'} + + )}
+