Added HMS health status; Added MQTT debug log window

This commit is contained in:
Martin Ziegler
2025-11-28 14:08:14 +01:00
parent b407e1b6d8
commit 8b2841be1e
12 changed files with 890 additions and 382 deletions
+77
View File
@@ -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"}
-1
View File
@@ -1,4 +1,3 @@
import json
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
+7
View File
@@ -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] = []
+84
View File
@@ -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
+31 -1
View File
@@ -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:
+37
View File
@@ -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<string, unknown>;
}
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<MQTTLogsResponse>(`/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 }),
}),
};
+229
View File
@@ -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<Set<number>>(new Set());
const logContainerRef = useRef<HTMLDivElement>(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<string, unknown>, 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 (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg max-w-4xl w-full max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div>
<h2 className="text-lg font-semibold text-white">MQTT Debug Log</h2>
<p className="text-sm text-bambu-gray">{printerName}</p>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Controls */}
<div className="flex items-center gap-2 p-4 border-b border-bambu-dark-tertiary">
{loggingEnabled ? (
<Button
size="sm"
variant="secondary"
onClick={() => disableMutation.mutate()}
disabled={disableMutation.isPending}
>
<Square className="w-4 h-4" />
Stop
</Button>
) : (
<Button
size="sm"
onClick={() => enableMutation.mutate()}
disabled={enableMutation.isPending}
>
<Play className="w-4 h-4" />
Start Logging
</Button>
)}
<Button
size="sm"
variant="secondary"
onClick={() => clearMutation.mutate()}
disabled={clearMutation.isPending || logs.length === 0}
>
<Trash2 className="w-4 h-4" />
Clear
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<div className="flex-1" />
<label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="rounded border-bambu-dark-tertiary"
/>
Auto-scroll
</label>
<span className="text-sm text-bambu-gray">
{logs.length} message{logs.length !== 1 ? 's' : ''}
</span>
</div>
{/* Log Content */}
<div
ref={logContainerRef}
className="flex-1 overflow-auto p-4 font-mono text-xs bg-bambu-dark min-h-[400px]"
>
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-bambu-gray">
<p className="mb-2">No messages logged yet</p>
{!loggingEnabled && (
<p className="text-sm">Click "Start Logging" to begin capturing MQTT messages</p>
)}
</div>
) : (
<div className="space-y-1">
{logs.map((log: MQTTLogEntry, index: number) => {
const isExpanded = expandedLogs.has(index);
const isIncoming = log.direction === 'in';
return (
<div
key={index}
className={`p-2 rounded cursor-pointer hover:bg-bambu-dark-secondary transition-colors ${
isExpanded ? 'bg-bambu-dark-secondary' : ''
}`}
onClick={() => toggleExpand(index)}
>
<div className="flex items-start gap-2">
<span className="text-bambu-gray shrink-0">
{formatTimestamp(log.timestamp)}
</span>
<span
className={`shrink-0 ${
isIncoming ? 'text-blue-400' : 'text-green-400'
}`}
title={isIncoming ? 'Incoming' : 'Outgoing'}
>
{isIncoming ? (
<ArrowDown className="w-3 h-3" />
) : (
<ArrowUp className="w-3 h-3" />
)}
</span>
<span className="text-purple-400 shrink-0">{log.topic}</span>
</div>
<pre
className={`mt-1 text-white/80 overflow-x-auto ${
isExpanded ? 'whitespace-pre-wrap' : 'truncate'
}`}
>
{formatPayload(log.payload, isExpanded)}
</pre>
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
<div className="text-sm text-bambu-gray">
{loggingEnabled ? (
<span className="flex items-center gap-2">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Logging active - messages will auto-refresh
</span>
) : (
<span>Logging stopped</span>
)}
</div>
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
);
}
+45
View File
@@ -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'}
</span>
{/* HMS Status Indicator */}
{status?.connected && (
<span
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
status.hms_errors && status.hms_errors.length > 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'
}
>
<AlertTriangle className="w-3 h-3" />
{status.hms_errors && status.hms_errors.length > 0
? status.hms_errors.length
: 'OK'}
</span>
)}
<div className="relative">
<Button
variant="ghost"
@@ -151,6 +177,16 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
<RefreshCw className="w-4 h-4" />
Reconnect
</button>
<button
className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
onClick={() => {
setShowMQTTDebug(true);
setShowMenu(false);
}}
>
<Terminal className="w-4 h-4" />
MQTT Debug
</button>
<button
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark-tertiary flex items-center gap-2"
onClick={() => {
@@ -289,6 +325,15 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
onClose={() => setShowFileManager(false)}
/>
)}
{/* MQTT Debug Modal */}
{showMQTTDebug && (
<MQTTDebugModal
printerId={printer.id}
printerName={printer.name}
onClose={() => setShowMQTTDebug(false)}
/>
)}
</Card>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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-DZsA8zAw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DwtaaDcN.css">
<script type="module" crossorigin src="/assets/index-BPRATuOd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DUX4pLTn.css">
</head>
<body>
<div id="root"></div>