mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-06-22 14:09:04 +01:00
added mcp initial trail files
This commit is contained in:
@@ -24,7 +24,7 @@ RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \
|
|||||||
|
|
||||||
# Install project and remaining dependencies (pip sees opencv-python already satisfied)
|
# Install project and remaining dependencies (pip sees opencv-python already satisfied)
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN pip install --no-cache-dir ".[standalone]"
|
RUN pip install --no-cache-dir ".[standalone,mcp]"
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -40,5 +40,6 @@ RUN python standalone/manage.py collectstatic --noinput 2>/dev/null || true
|
|||||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
EXPOSE 8808
|
||||||
|
|
||||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|||||||
@@ -51,5 +51,26 @@ class _Settings:
|
|||||||
def AUTO_CREATE_BRAND(self):
|
def AUTO_CREATE_BRAND(self):
|
||||||
return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
|
return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
|
||||||
|
|
||||||
|
# MCP Server settings
|
||||||
|
@property
|
||||||
|
def MCP_API_KEY(self):
|
||||||
|
return get_setting("BAMBU_RUN_MCP_API_KEY", None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def MCP_HOST(self):
|
||||||
|
return get_setting("BAMBU_RUN_MCP_HOST", "0.0.0.0")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def MCP_PORT(self):
|
||||||
|
return get_setting("BAMBU_RUN_MCP_PORT", 8808)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def MCP_AUTH_BACKEND(self):
|
||||||
|
return get_setting("BAMBU_RUN_MCP_AUTH_BACKEND", None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def MCP_HIDE_SENSITIVE(self):
|
||||||
|
return get_setting("BAMBU_RUN_MCP_HIDE_SENSITIVE", False)
|
||||||
|
|
||||||
|
|
||||||
app_settings = _Settings()
|
app_settings = _Settings()
|
||||||
|
|||||||
700
bambu_run/mcp_tools.py
Normal file
700
bambu_run/mcp_tools.py
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
"""
|
||||||
|
Pure Django ORM query functions for MCP tools.
|
||||||
|
|
||||||
|
Zero dependency on the `mcp` package — returns markdown strings.
|
||||||
|
RAE can reuse these directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db.models import Avg, Count, Max, Min, Q, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .conf import app_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(value, label="[redacted]"):
|
||||||
|
"""Redact sensitive values if MCP_HIDE_SENSITIVE is enabled."""
|
||||||
|
if app_settings.MCP_HIDE_SENSITIVE:
|
||||||
|
return label
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _format_duration(minutes):
|
||||||
|
"""Format minutes into human-readable duration."""
|
||||||
|
if minutes is None:
|
||||||
|
return "Unknown"
|
||||||
|
hours, mins = divmod(int(minutes), 60)
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}h {mins}m"
|
||||||
|
return f"{mins}m"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_temp(temp):
|
||||||
|
"""Format temperature value."""
|
||||||
|
if temp is None:
|
||||||
|
return "N/A"
|
||||||
|
return f"{temp}°C"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tools ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_printer_status(printer_id=None):
|
||||||
|
"""Current live status of printer(s) including temps, progress, AMS, errors."""
|
||||||
|
from .models import Printer, PrinterMetrics
|
||||||
|
|
||||||
|
printers = Printer.objects.filter(is_active=True)
|
||||||
|
if printer_id:
|
||||||
|
printers = printers.filter(id=printer_id)
|
||||||
|
|
||||||
|
if not printers.exists():
|
||||||
|
return "No printers found."
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for printer in printers:
|
||||||
|
metric = PrinterMetrics.objects.filter(device=printer).first()
|
||||||
|
if not metric:
|
||||||
|
parts.append(f"## {printer.name}\n**No data available yet.**\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
state = metric.gcode_state or "Unknown"
|
||||||
|
lines = [f"## Printer Status: {printer.name}"]
|
||||||
|
lines.append(f"**Model**: {printer.model} | **Serial**: {_redact(printer.serial_number)}")
|
||||||
|
lines.append(f"**IP**: {_redact(printer.ip_address)} | **Location**: {printer.location or 'N/A'}")
|
||||||
|
lines.append(f"**State**: {state}")
|
||||||
|
|
||||||
|
if metric.print_percent is not None and state == "RUNNING":
|
||||||
|
layer_info = ""
|
||||||
|
if metric.layer_num is not None and metric.total_layer_num:
|
||||||
|
layer_info = f" (Layer {metric.layer_num}/{metric.total_layer_num})"
|
||||||
|
lines.append(f"**Progress**: {metric.print_percent}%{layer_info}")
|
||||||
|
if metric.subtask_name:
|
||||||
|
lines.append(f"**Project**: {metric.subtask_name}")
|
||||||
|
if metric.remaining_time_min:
|
||||||
|
lines.append(f"**ETA**: {_format_duration(metric.remaining_time_min)} remaining")
|
||||||
|
|
||||||
|
# Temperatures
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### Temperatures")
|
||||||
|
lines.append("| Component | Current | Target |")
|
||||||
|
lines.append("|-----------|---------|--------|")
|
||||||
|
lines.append(f"| Nozzle | {_format_temp(metric.nozzle_temp)} | {_format_temp(metric.nozzle_target_temp)} |")
|
||||||
|
lines.append(f"| Bed | {_format_temp(metric.bed_temp)} | {_format_temp(metric.bed_target_temp)} |")
|
||||||
|
lines.append(f"| Chamber | {_format_temp(metric.chamber_temp)} | - |")
|
||||||
|
|
||||||
|
# AMS filaments from JSON
|
||||||
|
if metric.filaments:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### AMS Slots")
|
||||||
|
lines.append("| Slot | Material | Color | Remaining |")
|
||||||
|
lines.append("|------|----------|-------|-----------|")
|
||||||
|
for f in metric.filaments:
|
||||||
|
slot = f.get("slot", "?")
|
||||||
|
ftype = f.get("sub_type") or f.get("type", "?")
|
||||||
|
color = f.get("color", "")
|
||||||
|
color_display = f"#{color[:6]}" if color and len(color) >= 6 else "?"
|
||||||
|
remain = f.get("remain_percent", "?")
|
||||||
|
lines.append(f"| {slot} | {ftype} | {color_display} | {remain}% |")
|
||||||
|
|
||||||
|
# Errors
|
||||||
|
if metric.has_errors or metric.hms:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### Alerts")
|
||||||
|
if metric.print_error:
|
||||||
|
lines.append(f"- Print error code: {metric.print_error}")
|
||||||
|
if metric.hms:
|
||||||
|
for msg in metric.hms[:5]:
|
||||||
|
lines.append(f"- HMS: {msg}")
|
||||||
|
|
||||||
|
lines.append(f"\n*Last updated: {metric.timestamp.strftime('%Y-%m-%d %H:%M:%S')}*")
|
||||||
|
parts.append("\n".join(lines))
|
||||||
|
|
||||||
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def list_printers():
|
||||||
|
"""List all registered printers."""
|
||||||
|
from .models import Printer
|
||||||
|
|
||||||
|
printers = Printer.objects.all()
|
||||||
|
if not printers.exists():
|
||||||
|
return "No printers registered."
|
||||||
|
|
||||||
|
lines = ["# Printers", ""]
|
||||||
|
lines.append("| ID | Name | Model | Active | Serial | IP | Location |")
|
||||||
|
lines.append("|----|------|-------|--------|--------|----|----------|")
|
||||||
|
for p in printers:
|
||||||
|
lines.append(
|
||||||
|
f"| {p.id} | {p.name} | {p.model} | "
|
||||||
|
f"{'Yes' if p.is_active else 'No'} | "
|
||||||
|
f"{_redact(p.serial_number)} | {_redact(p.ip_address)} | "
|
||||||
|
f"{p.location or '-'} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_print_history(status=None, days=None, project_name=None, limit=20):
|
||||||
|
"""Print job history with optional filters."""
|
||||||
|
from .models import PrintJob
|
||||||
|
|
||||||
|
qs = PrintJob.objects.select_related("device")
|
||||||
|
|
||||||
|
if status:
|
||||||
|
qs = qs.filter(final_status__iexact=status)
|
||||||
|
if days:
|
||||||
|
cutoff = timezone.now() - timedelta(days=int(days))
|
||||||
|
qs = qs.filter(start_time__gte=cutoff)
|
||||||
|
if project_name:
|
||||||
|
qs = qs.filter(project_name__icontains=project_name)
|
||||||
|
|
||||||
|
jobs = qs[:int(limit)]
|
||||||
|
if not jobs:
|
||||||
|
return "No print jobs found matching the criteria."
|
||||||
|
|
||||||
|
lines = ["# Print History", ""]
|
||||||
|
lines.append("| ID | Project | Printer | Status | Progress | Duration | Started |")
|
||||||
|
lines.append("|----|---------|---------|--------|----------|----------|---------|")
|
||||||
|
for j in jobs:
|
||||||
|
lines.append(
|
||||||
|
f"| {j.id} | {j.project_name} | {j.device.name} | "
|
||||||
|
f"{j.final_status or 'In Progress'} | {j.completion_percent}% | "
|
||||||
|
f"{_format_duration(j.duration_minutes)} | "
|
||||||
|
f"{j.start_time.strftime('%Y-%m-%d %H:%M')} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_print_job_detail(job_id):
|
||||||
|
"""Single job detail including filament usage."""
|
||||||
|
from .models import FilamentUsage, PrintJob
|
||||||
|
|
||||||
|
try:
|
||||||
|
job = PrintJob.objects.select_related("device").get(id=job_id)
|
||||||
|
except PrintJob.DoesNotExist:
|
||||||
|
return f"Print job #{job_id} not found."
|
||||||
|
|
||||||
|
lines = [f"# Print Job: {job.project_name}", ""]
|
||||||
|
lines.append(f"**Printer**: {job.device.name}")
|
||||||
|
lines.append(f"**Status**: {job.final_status or 'In Progress'}")
|
||||||
|
lines.append(f"**Progress**: {job.completion_percent}%")
|
||||||
|
if job.gcode_file:
|
||||||
|
lines.append(f"**G-code**: {job.gcode_file}")
|
||||||
|
lines.append(f"**Started**: {job.start_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
if job.end_time:
|
||||||
|
lines.append(f"**Ended**: {job.end_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
lines.append(f"**Duration**: {_format_duration(job.duration_minutes)}")
|
||||||
|
if job.total_layers:
|
||||||
|
lines.append(f"**Total Layers**: {job.total_layers}")
|
||||||
|
|
||||||
|
# Filament usage
|
||||||
|
usages = FilamentUsage.objects.select_related("filament").filter(print_job=job)
|
||||||
|
if usages.exists():
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### Filament Usage")
|
||||||
|
lines.append("| Spool | Material | Color | Consumed | Grams |")
|
||||||
|
lines.append("|-------|----------|-------|----------|-------|")
|
||||||
|
for u in usages:
|
||||||
|
f = u.filament
|
||||||
|
lines.append(
|
||||||
|
f"| {f.brand} {f.type} | {f.sub_type or f.type} | "
|
||||||
|
f"{f.color} | {u.consumed_percent or 0}% | "
|
||||||
|
f"{u.consumed_grams or '-'}g |"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def list_filaments(type=None, brand=None, color=None, loaded_in_ams=None, low_filament=None):
|
||||||
|
"""Filament inventory with optional filters."""
|
||||||
|
from .models import Filament
|
||||||
|
|
||||||
|
qs = Filament.objects.all()
|
||||||
|
if type:
|
||||||
|
qs = qs.filter(type__iexact=type)
|
||||||
|
if brand:
|
||||||
|
qs = qs.filter(brand__icontains=brand)
|
||||||
|
if color:
|
||||||
|
qs = qs.filter(color__icontains=color)
|
||||||
|
if loaded_in_ams is not None:
|
||||||
|
qs = qs.filter(is_loaded_in_ams=loaded_in_ams)
|
||||||
|
if low_filament:
|
||||||
|
qs = qs.filter(remaining_percent__lte=20)
|
||||||
|
|
||||||
|
filaments = qs[:50]
|
||||||
|
if not filaments:
|
||||||
|
return "No filaments found matching the criteria."
|
||||||
|
|
||||||
|
lines = ["# Filament Inventory", ""]
|
||||||
|
lines.append(f"*{qs.count()} spools total*\n")
|
||||||
|
lines.append("| ID | Brand | Type | Color | Remaining | In AMS | Last Used |")
|
||||||
|
lines.append("|----|-------|------|-------|-----------|--------|-----------|")
|
||||||
|
for f in filaments:
|
||||||
|
color_display = f"{f.color}"
|
||||||
|
if f.color_hex:
|
||||||
|
color_display += f" ({f.color_hex})"
|
||||||
|
last_used = f.last_used.strftime("%Y-%m-%d") if f.last_used else "-"
|
||||||
|
lines.append(
|
||||||
|
f"| {f.id} | {f.brand} | {f.sub_type or f.type} | "
|
||||||
|
f"{color_display} | {f.remaining_percent}% | "
|
||||||
|
f"{'Yes' if f.is_loaded_in_ams else 'No'} | {last_used} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_filament_detail(filament_id):
|
||||||
|
"""Single spool detail with usage history."""
|
||||||
|
from .models import Filament, FilamentUsage
|
||||||
|
|
||||||
|
try:
|
||||||
|
f = Filament.objects.get(id=filament_id)
|
||||||
|
except Filament.DoesNotExist:
|
||||||
|
return f"Filament #{filament_id} not found."
|
||||||
|
|
||||||
|
lines = [f"# Filament: {f.brand} {f.type} - {f.color}", ""]
|
||||||
|
lines.append(f"**Type**: {f.sub_type or f.type}")
|
||||||
|
lines.append(f"**Brand**: {f.brand}")
|
||||||
|
lines.append(f"**Color**: {f.color} ({f.color_hex or 'N/A'})")
|
||||||
|
lines.append(f"**Remaining**: {f.remaining_percent}%")
|
||||||
|
if f.remaining_weight_grams:
|
||||||
|
lines.append(f"**Remaining Weight**: {f.remaining_weight_grams}g / {f.initial_weight_grams or '?'}g")
|
||||||
|
lines.append(f"**In AMS**: {'Yes (slot ' + str(f.current_tray_id) + ')' if f.is_loaded_in_ams else 'No'}")
|
||||||
|
lines.append(f"**Created By**: {f.created_by}")
|
||||||
|
if f.tray_uuid:
|
||||||
|
lines.append(f"**Serial**: {_redact(f.tray_uuid)}")
|
||||||
|
if f.purchase_date:
|
||||||
|
lines.append(f"**Purchased**: {f.purchase_date}")
|
||||||
|
if f.notes:
|
||||||
|
lines.append(f"**Notes**: {f.notes}")
|
||||||
|
|
||||||
|
# Usage history
|
||||||
|
usages = FilamentUsage.objects.select_related("print_job").filter(filament=f).order_by("-print_job__start_time")[:10]
|
||||||
|
if usages.exists():
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### Recent Print Usage")
|
||||||
|
lines.append("| Job | Date | Consumed | Grams |")
|
||||||
|
lines.append("|-----|------|----------|-------|")
|
||||||
|
for u in usages:
|
||||||
|
lines.append(
|
||||||
|
f"| {u.print_job.project_name} | "
|
||||||
|
f"{u.print_job.start_time.strftime('%Y-%m-%d')} | "
|
||||||
|
f"{u.consumed_percent or 0}% | {u.consumed_grams or '-'}g |"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_temperature_history(printer_id=None, hours=6, metric="all"):
|
||||||
|
"""Temperature trends as summary stats (avg/min/max) over recent hours."""
|
||||||
|
from .models import Printer, PrinterMetrics
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(hours=int(hours))
|
||||||
|
|
||||||
|
qs = PrinterMetrics.objects.filter(timestamp__gte=cutoff)
|
||||||
|
if printer_id:
|
||||||
|
qs = qs.filter(device_id=printer_id)
|
||||||
|
|
||||||
|
if not qs.exists():
|
||||||
|
return f"No temperature data in the last {hours} hours."
|
||||||
|
|
||||||
|
printers = Printer.objects.filter(
|
||||||
|
id__in=qs.values_list("device_id", flat=True).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
parts = [f"# Temperature History (last {hours}h)", ""]
|
||||||
|
for printer in printers:
|
||||||
|
pqs = qs.filter(device=printer)
|
||||||
|
stats = pqs.aggregate(
|
||||||
|
nozzle_avg=Avg("nozzle_temp"),
|
||||||
|
nozzle_min=Min("nozzle_temp"),
|
||||||
|
nozzle_max=Max("nozzle_temp"),
|
||||||
|
bed_avg=Avg("bed_temp"),
|
||||||
|
bed_min=Min("bed_temp"),
|
||||||
|
bed_max=Max("bed_temp"),
|
||||||
|
chamber_avg=Avg("chamber_temp"),
|
||||||
|
chamber_min=Min("chamber_temp"),
|
||||||
|
chamber_max=Max("chamber_temp"),
|
||||||
|
)
|
||||||
|
|
||||||
|
parts.append(f"## {printer.name}")
|
||||||
|
parts.append(f"*{pqs.count()} data points*\n")
|
||||||
|
parts.append("| Sensor | Avg | Min | Max |")
|
||||||
|
parts.append("|--------|-----|-----|-----|")
|
||||||
|
|
||||||
|
if metric in ("all", "nozzle"):
|
||||||
|
parts.append(
|
||||||
|
f"| Nozzle | {_format_temp(stats['nozzle_avg'])} | "
|
||||||
|
f"{_format_temp(stats['nozzle_min'])} | {_format_temp(stats['nozzle_max'])} |"
|
||||||
|
)
|
||||||
|
if metric in ("all", "bed"):
|
||||||
|
parts.append(
|
||||||
|
f"| Bed | {_format_temp(stats['bed_avg'])} | "
|
||||||
|
f"{_format_temp(stats['bed_min'])} | {_format_temp(stats['bed_max'])} |"
|
||||||
|
)
|
||||||
|
if metric in ("all", "chamber"):
|
||||||
|
parts.append(
|
||||||
|
f"| Chamber | {_format_temp(stats['chamber_avg'])} | "
|
||||||
|
f"{_format_temp(stats['chamber_min'])} | {_format_temp(stats['chamber_max'])} |"
|
||||||
|
)
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def get_filament_usage_stats(days=30, group_by="type"):
|
||||||
|
"""Aggregate filament consumption statistics."""
|
||||||
|
from .models import FilamentUsage
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(days=int(days))
|
||||||
|
qs = FilamentUsage.objects.filter(
|
||||||
|
print_job__start_time__gte=cutoff,
|
||||||
|
consumed_grams__isnull=False,
|
||||||
|
).select_related("filament")
|
||||||
|
|
||||||
|
if not qs.exists():
|
||||||
|
return f"No filament usage data in the last {days} days."
|
||||||
|
|
||||||
|
lines = [f"# Filament Usage Stats (last {days} days)", ""]
|
||||||
|
|
||||||
|
if group_by == "type":
|
||||||
|
stats = (
|
||||||
|
qs.values("filament__type")
|
||||||
|
.annotate(
|
||||||
|
total_grams=Sum("consumed_grams"),
|
||||||
|
total_percent=Sum("consumed_percent"),
|
||||||
|
job_count=Count("print_job", distinct=True),
|
||||||
|
)
|
||||||
|
.order_by("-total_grams")
|
||||||
|
)
|
||||||
|
lines.append("| Type | Total Grams | Jobs | Avg Grams/Job |")
|
||||||
|
lines.append("|------|-------------|------|---------------|")
|
||||||
|
for s in stats:
|
||||||
|
avg = s["total_grams"] / s["job_count"] if s["job_count"] else 0
|
||||||
|
lines.append(
|
||||||
|
f"| {s['filament__type']} | {s['total_grams']}g | "
|
||||||
|
f"{s['job_count']} | {avg:.0f}g |"
|
||||||
|
)
|
||||||
|
elif group_by == "color":
|
||||||
|
stats = (
|
||||||
|
qs.values("filament__color", "filament__type")
|
||||||
|
.annotate(total_grams=Sum("consumed_grams"), job_count=Count("print_job", distinct=True))
|
||||||
|
.order_by("-total_grams")
|
||||||
|
)
|
||||||
|
lines.append("| Color | Type | Total Grams | Jobs |")
|
||||||
|
lines.append("|-------|------|-------------|------|")
|
||||||
|
for s in stats:
|
||||||
|
lines.append(
|
||||||
|
f"| {s['filament__color']} | {s['filament__type']} | "
|
||||||
|
f"{s['total_grams']}g | {s['job_count']} |"
|
||||||
|
)
|
||||||
|
elif group_by == "spool":
|
||||||
|
stats = (
|
||||||
|
qs.values("filament__id", "filament__brand", "filament__type", "filament__color")
|
||||||
|
.annotate(total_grams=Sum("consumed_grams"), job_count=Count("print_job", distinct=True))
|
||||||
|
.order_by("-total_grams")[:20]
|
||||||
|
)
|
||||||
|
lines.append("| Spool | Total Grams | Jobs |")
|
||||||
|
lines.append("|-------|-------------|------|")
|
||||||
|
for s in stats:
|
||||||
|
lines.append(
|
||||||
|
f"| {s['filament__brand']} {s['filament__type']} {s['filament__color']} | "
|
||||||
|
f"{s['total_grams']}g | {s['job_count']} |"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_printer_health(printer_id=None):
|
||||||
|
"""Diagnostics: errors, humidity, wifi, recent failed prints."""
|
||||||
|
from .models import Printer, PrinterMetrics, PrintJob
|
||||||
|
|
||||||
|
printers = Printer.objects.filter(is_active=True)
|
||||||
|
if printer_id:
|
||||||
|
printers = printers.filter(id=printer_id)
|
||||||
|
|
||||||
|
if not printers.exists():
|
||||||
|
return "No printers found."
|
||||||
|
|
||||||
|
parts = ["# Printer Health Report", ""]
|
||||||
|
for printer in printers:
|
||||||
|
latest = PrinterMetrics.objects.filter(device=printer).first()
|
||||||
|
if not latest:
|
||||||
|
parts.append(f"## {printer.name}\n**No data available.**\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts.append(f"## {printer.name}")
|
||||||
|
|
||||||
|
# Connectivity
|
||||||
|
parts.append("### Connectivity")
|
||||||
|
if latest.wifi_signal_dbm is not None:
|
||||||
|
signal = latest.wifi_signal_dbm
|
||||||
|
quality = "Excellent" if signal > -50 else "Good" if signal > -60 else "Fair" if signal > -70 else "Poor"
|
||||||
|
parts.append(f"- WiFi: {signal} dBm ({quality})")
|
||||||
|
parts.append(f"- Last seen: {latest.timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
age = (timezone.now() - latest.timestamp).total_seconds()
|
||||||
|
if age > 300:
|
||||||
|
parts.append(f"- **Warning**: No data for {_format_duration(age / 60)}")
|
||||||
|
|
||||||
|
# AMS environment
|
||||||
|
if latest.ams_humidity is not None or latest.ams_temp is not None:
|
||||||
|
parts.append("### AMS Environment")
|
||||||
|
if latest.ams_humidity is not None:
|
||||||
|
hum_status = "OK" if latest.ams_humidity < 5 else "High" if latest.ams_humidity < 8 else "Critical"
|
||||||
|
parts.append(f"- Humidity: {latest.ams_humidity} ({hum_status})")
|
||||||
|
if latest.ams_temp is not None:
|
||||||
|
parts.append(f"- Temperature: {latest.ams_temp}°C")
|
||||||
|
|
||||||
|
# HMS errors
|
||||||
|
if latest.hms:
|
||||||
|
parts.append("### Active HMS Alerts")
|
||||||
|
for msg in latest.hms:
|
||||||
|
parts.append(f"- {msg}")
|
||||||
|
|
||||||
|
# Recent failures
|
||||||
|
week_ago = timezone.now() - timedelta(days=7)
|
||||||
|
failed = PrintJob.objects.filter(
|
||||||
|
device=printer,
|
||||||
|
start_time__gte=week_ago,
|
||||||
|
final_status__in=["FAILED", "CANCELLED"],
|
||||||
|
)
|
||||||
|
if failed.exists():
|
||||||
|
parts.append(f"### Recent Failures (7d): {failed.count()}")
|
||||||
|
for job in failed[:5]:
|
||||||
|
parts.append(f"- {job.project_name} ({job.final_status}) — {job.start_time.strftime('%m-%d %H:%M')}")
|
||||||
|
|
||||||
|
# Success rate
|
||||||
|
week_jobs = PrintJob.objects.filter(device=printer, start_time__gte=week_ago)
|
||||||
|
total = week_jobs.count()
|
||||||
|
if total > 0:
|
||||||
|
success = week_jobs.filter(final_status="FINISH").count()
|
||||||
|
parts.append(f"\n**7-day success rate**: {success}/{total} ({100 * success // total}%)")
|
||||||
|
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def search_print_jobs(query):
|
||||||
|
"""Search print jobs by project name or gcode file."""
|
||||||
|
from .models import PrintJob
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return "Please provide a search query."
|
||||||
|
|
||||||
|
jobs = PrintJob.objects.select_related("device").filter(
|
||||||
|
Q(project_name__icontains=query) | Q(gcode_file__icontains=query)
|
||||||
|
)[:20]
|
||||||
|
|
||||||
|
if not jobs:
|
||||||
|
return f"No print jobs matching '{query}'."
|
||||||
|
|
||||||
|
lines = [f"# Search Results: '{query}'", ""]
|
||||||
|
lines.append(f"*{len(jobs)} results*\n")
|
||||||
|
lines.append("| ID | Project | Printer | Status | Date |")
|
||||||
|
lines.append("|----|---------|---------|--------|------|")
|
||||||
|
for j in jobs:
|
||||||
|
lines.append(
|
||||||
|
f"| {j.id} | {j.project_name} | {j.device.name} | "
|
||||||
|
f"{j.final_status or 'In Progress'} | {j.start_time.strftime('%Y-%m-%d')} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_printing_summary(days=7):
|
||||||
|
"""High-level activity summary."""
|
||||||
|
from .models import FilamentUsage, Printer, PrintJob
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(days=int(days))
|
||||||
|
jobs = PrintJob.objects.filter(start_time__gte=cutoff)
|
||||||
|
|
||||||
|
total = jobs.count()
|
||||||
|
finished = jobs.filter(final_status="FINISH").count()
|
||||||
|
failed = jobs.filter(final_status="FAILED").count()
|
||||||
|
cancelled = jobs.filter(final_status="CANCELLED").count()
|
||||||
|
in_progress = jobs.filter(final_status__isnull=True).count()
|
||||||
|
|
||||||
|
total_minutes = jobs.filter(duration_minutes__isnull=False).aggregate(
|
||||||
|
total=Sum("duration_minutes")
|
||||||
|
)["total"] or 0
|
||||||
|
|
||||||
|
total_grams = FilamentUsage.objects.filter(
|
||||||
|
print_job__start_time__gte=cutoff,
|
||||||
|
consumed_grams__isnull=False,
|
||||||
|
).aggregate(total=Sum("consumed_grams"))["total"] or 0
|
||||||
|
|
||||||
|
lines = [f"# Printing Summary (last {days} days)", ""]
|
||||||
|
lines.append(f"**Total Jobs**: {total}")
|
||||||
|
lines.append(f"- Completed: {finished}")
|
||||||
|
lines.append(f"- Failed: {failed}")
|
||||||
|
lines.append(f"- Cancelled: {cancelled}")
|
||||||
|
lines.append(f"- In Progress: {in_progress}")
|
||||||
|
if total > 0:
|
||||||
|
lines.append(f"- Success Rate: {100 * finished // total}%")
|
||||||
|
lines.append(f"\n**Total Print Time**: {_format_duration(total_minutes)}")
|
||||||
|
lines.append(f"**Total Filament Used**: {total_grams}g")
|
||||||
|
|
||||||
|
# Most printed projects
|
||||||
|
top_projects = (
|
||||||
|
jobs.values("project_name")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")[:5]
|
||||||
|
)
|
||||||
|
if top_projects:
|
||||||
|
lines.append("\n### Most Printed")
|
||||||
|
for p in top_projects:
|
||||||
|
lines.append(f"- {p['project_name']} ({p['count']}x)")
|
||||||
|
|
||||||
|
# Active printers
|
||||||
|
active_printers = Printer.objects.filter(
|
||||||
|
print_jobs__start_time__gte=cutoff
|
||||||
|
).distinct()
|
||||||
|
if active_printers.exists():
|
||||||
|
lines.append(f"\n**Active Printers**: {', '.join(p.name for p in active_printers)}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def find_compatible_filament(type, min_remaining_percent=10, color=None):
|
||||||
|
"""Find spools matching material type criteria."""
|
||||||
|
from .models import Filament
|
||||||
|
|
||||||
|
qs = Filament.objects.filter(
|
||||||
|
type__iexact=type,
|
||||||
|
remaining_percent__gte=int(min_remaining_percent),
|
||||||
|
)
|
||||||
|
if color:
|
||||||
|
qs = qs.filter(color__icontains=color)
|
||||||
|
|
||||||
|
filaments = qs[:20]
|
||||||
|
if not filaments:
|
||||||
|
return f"No {type} filament found with >={min_remaining_percent}% remaining."
|
||||||
|
|
||||||
|
lines = [f"# Compatible Filament: {type}", ""]
|
||||||
|
if color:
|
||||||
|
lines.append(f"*Color filter: {color}*\n")
|
||||||
|
lines.append(f"*{qs.count()} spools found*\n")
|
||||||
|
lines.append("| ID | Brand | Sub-type | Color | Remaining | In AMS |")
|
||||||
|
lines.append("|----|-------|----------|-------|-----------|--------|")
|
||||||
|
for f in filaments:
|
||||||
|
lines.append(
|
||||||
|
f"| {f.id} | {f.brand} | {f.sub_type or f.type} | "
|
||||||
|
f"{f.color} | {f.remaining_percent}% | "
|
||||||
|
f"{'Yes' if f.is_loaded_in_ams else 'No'} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Resources ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def resource_printers():
|
||||||
|
"""List all printers (resource)."""
|
||||||
|
return list_printers()
|
||||||
|
|
||||||
|
|
||||||
|
def resource_printer_status(printer_id):
|
||||||
|
"""Latest printer status (resource)."""
|
||||||
|
return get_printer_status(printer_id=printer_id)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_filaments():
|
||||||
|
"""Full filament inventory (resource)."""
|
||||||
|
return list_filaments()
|
||||||
|
|
||||||
|
|
||||||
|
def resource_filament_detail(filament_id):
|
||||||
|
"""Single spool with usage (resource)."""
|
||||||
|
return get_filament_detail(filament_id=filament_id)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_recent_print_jobs():
|
||||||
|
"""Last 20 print jobs (resource)."""
|
||||||
|
return get_print_history(limit=20)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_filament_types():
|
||||||
|
"""Filament type registry (resource)."""
|
||||||
|
from .models import FilamentType
|
||||||
|
|
||||||
|
types = FilamentType.objects.all()
|
||||||
|
if not types.exists():
|
||||||
|
return "No filament types registered."
|
||||||
|
|
||||||
|
lines = ["# Filament Types", ""]
|
||||||
|
lines.append("| ID | Type | Sub-type | Brand |")
|
||||||
|
lines.append("|----|------|----------|-------|")
|
||||||
|
for t in types:
|
||||||
|
lines.append(f"| {t.id} | {t.type} | {t.sub_type or '-'} | {t.brand} |")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_filament_colors():
|
||||||
|
"""Filament color database (resource)."""
|
||||||
|
from .models import FilamentColor
|
||||||
|
|
||||||
|
colors = FilamentColor.objects.all()[:100]
|
||||||
|
if not colors:
|
||||||
|
return "No filament colors in database."
|
||||||
|
|
||||||
|
lines = ["# Filament Colors", ""]
|
||||||
|
lines.append(f"*Showing up to 100 of {FilamentColor.objects.count()}*\n")
|
||||||
|
lines.append("| Color | Hex | Type | Sub-type | Brand |")
|
||||||
|
lines.append("|-------|-----|------|----------|-------|")
|
||||||
|
for c in colors:
|
||||||
|
lines.append(
|
||||||
|
f"| {c.color_name} | #{c.color_code} | {c.filament_type} | "
|
||||||
|
f"{c.filament_sub_type or '-'} | {c.brand} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Prompts ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_printer_check_in(printer_id=None):
|
||||||
|
"""Full status briefing: status + health + recent prints."""
|
||||||
|
parts = [
|
||||||
|
get_printer_status(printer_id=printer_id),
|
||||||
|
get_printer_health(printer_id=printer_id),
|
||||||
|
get_print_history(days=1, limit=5),
|
||||||
|
]
|
||||||
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_filament_inventory_report():
|
||||||
|
"""Inventory report with low-stock warnings."""
|
||||||
|
from .models import Filament
|
||||||
|
|
||||||
|
low_stock = Filament.objects.filter(remaining_percent__lte=20)
|
||||||
|
parts = [list_filaments()]
|
||||||
|
if low_stock.exists():
|
||||||
|
lines = ["\n## Low Stock Warnings"]
|
||||||
|
for f in low_stock:
|
||||||
|
lines.append(f"- **{f.brand} {f.type} {f.color}**: {f.remaining_percent}% remaining")
|
||||||
|
parts.append("\n".join(lines))
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_print_job_review(job_id):
|
||||||
|
"""Review a completed job."""
|
||||||
|
return get_print_job_detail(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_weekly_digest():
|
||||||
|
"""Weekly activity summary."""
|
||||||
|
parts = [
|
||||||
|
get_printing_summary(days=7),
|
||||||
|
get_filament_usage_stats(days=7, group_by="type"),
|
||||||
|
]
|
||||||
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_troubleshoot_printer(printer_id=None):
|
||||||
|
"""Diagnose issues from recent data."""
|
||||||
|
parts = [
|
||||||
|
get_printer_health(printer_id=printer_id),
|
||||||
|
get_printer_status(printer_id=printer_id),
|
||||||
|
get_temperature_history(printer_id=printer_id, hours=2),
|
||||||
|
]
|
||||||
|
return "\n\n---\n\n".join(parts)
|
||||||
@@ -3,6 +3,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
- "8808:8808"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- bambu_data:/app/data
|
- bambu_data:/app/data
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ autorestart=true
|
|||||||
startretries=10
|
startretries=10
|
||||||
startsecs=5
|
startsecs=5
|
||||||
|
|
||||||
|
[program:mcp_server]
|
||||||
|
command=python standalone/manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
|
||||||
|
directory=/app
|
||||||
|
environment=DJANGO_SETTINGS_MODULE="standalone.settings"
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
autorestart=true
|
||||||
|
startretries=10
|
||||||
|
startsecs=5
|
||||||
|
|
||||||
[program:migrate]
|
[program:migrate]
|
||||||
command=python standalone/manage.py migrate --noinput
|
command=python standalone/manage.py migrate --noinput
|
||||||
directory=/app
|
directory=/app
|
||||||
|
|||||||
15
native/bambu-run-mcp.service
Normal file
15
native/bambu-run-mcp.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Bambu-Run MCP Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
WorkingDirectory={{REPO_DIR}}
|
||||||
|
EnvironmentFile={{REPO_DIR}}/.env
|
||||||
|
Environment=DJANGO_SETTINGS_MODULE=standalone.settings
|
||||||
|
ExecStart={{VENV_DIR}}/bin/python standalone/manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -8,6 +8,12 @@ VENV_DIR="$REPO_DIR/.venv"
|
|||||||
MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py"
|
MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py"
|
||||||
SERVICES="bambu-run-web.service bambu-run-collector.service"
|
SERVICES="bambu-run-web.service bambu-run-collector.service"
|
||||||
|
|
||||||
|
# Include MCP service if installed
|
||||||
|
SERVICE_DIR="$HOME/.config/systemd/user"
|
||||||
|
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
|
||||||
|
SERVICES="$SERVICES bambu-run-mcp.service"
|
||||||
|
fi
|
||||||
|
|
||||||
case "${1:-help}" in
|
case "${1:-help}" in
|
||||||
start)
|
start)
|
||||||
systemctl --user start $SERVICES
|
systemctl --user start $SERVICES
|
||||||
@@ -25,14 +31,22 @@ case "${1:-help}" in
|
|||||||
systemctl --user status $SERVICES --no-pager
|
systemctl --user status $SERVICES --no-pager
|
||||||
;;
|
;;
|
||||||
logs)
|
logs)
|
||||||
journalctl --user -u bambu-run-web -u bambu-run-collector -f --no-hostname
|
JOURNAL_UNITS="-u bambu-run-web -u bambu-run-collector"
|
||||||
|
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
|
||||||
|
JOURNAL_UNITS="$JOURNAL_UNITS -u bambu-run-mcp"
|
||||||
|
fi
|
||||||
|
journalctl --user $JOURNAL_UNITS -f --no-hostname
|
||||||
;;
|
;;
|
||||||
update)
|
update)
|
||||||
echo "Pulling latest code..."
|
echo "Pulling latest code..."
|
||||||
cd "$REPO_DIR" && git pull
|
cd "$REPO_DIR" && git pull
|
||||||
|
|
||||||
echo "Installing dependencies..."
|
echo "Installing dependencies..."
|
||||||
"$VENV_DIR/bin/pip" install --quiet ".[standalone]"
|
EXTRAS="standalone"
|
||||||
|
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
|
||||||
|
EXTRAS="standalone,mcp"
|
||||||
|
fi
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet ".[$EXTRAS]"
|
||||||
|
|
||||||
echo "Running migrations..."
|
echo "Running migrations..."
|
||||||
$MANAGE migrate --noinput
|
$MANAGE migrate --noinput
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ standalone = [
|
|||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
"whitenoise",
|
"whitenoise",
|
||||||
]
|
]
|
||||||
|
mcp = [
|
||||||
|
"mcp[cli]>=1.0",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff",
|
"ruff",
|
||||||
"pytest",
|
"pytest",
|
||||||
|
|||||||
27
setup.sh
27
setup.sh
@@ -201,6 +201,25 @@ sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \
|
|||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable bambu-run-web.service bambu-run-collector.service
|
systemctl --user enable bambu-run-web.service bambu-run-collector.service
|
||||||
|
|
||||||
|
# ── 9b. Optional MCP server ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo
|
||||||
|
MCP_ENABLED=false
|
||||||
|
read -rp "Enable MCP server for AI agent access (Claude Desktop, Claude Code, etc.)? [y/N] " ENABLE_MCP
|
||||||
|
if [[ "$ENABLE_MCP" =~ ^[Yy] ]]; then
|
||||||
|
green "Installing MCP dependencies..."
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet ".[mcp]"
|
||||||
|
|
||||||
|
sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \
|
||||||
|
"$REPO_DIR/native/bambu-run-mcp.service" > "$SERVICE_DIR/bambu-run-mcp.service"
|
||||||
|
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable bambu-run-mcp.service
|
||||||
|
systemctl --user start bambu-run-mcp.service
|
||||||
|
MCP_ENABLED=true
|
||||||
|
green "MCP server enabled on port 8808."
|
||||||
|
fi
|
||||||
|
|
||||||
# Enable linger so services survive SSH logout
|
# Enable linger so services survive SSH logout
|
||||||
loginctl enable-linger "$USER" 2>/dev/null || \
|
loginctl enable-linger "$USER" 2>/dev/null || \
|
||||||
sudo loginctl enable-linger "$USER" 2>/dev/null || \
|
sudo loginctl enable-linger "$USER" 2>/dev/null || \
|
||||||
@@ -255,9 +274,17 @@ green " Bambu-Run is running!"
|
|||||||
green "============================================"
|
green "============================================"
|
||||||
echo
|
echo
|
||||||
echo " Dashboard: $DASHBOARD_URL"
|
echo " Dashboard: $DASHBOARD_URL"
|
||||||
|
if [ "$MCP_ENABLED" = true ]; then
|
||||||
|
echo " MCP Server: http://${PI_IP:-localhost}:8808/sse"
|
||||||
|
fi
|
||||||
echo " Status: systemctl --user status bambu-run-web bambu-run-collector"
|
echo " Status: systemctl --user status bambu-run-web bambu-run-collector"
|
||||||
echo " Logs: journalctl --user -u bambu-run-web -u bambu-run-collector -f"
|
echo " Logs: journalctl --user -u bambu-run-web -u bambu-run-collector -f"
|
||||||
echo " Helper: ./native/bambu-run.sh {start|stop|restart|status|logs|update}"
|
echo " Helper: ./native/bambu-run.sh {start|stop|restart|status|logs|update}"
|
||||||
echo
|
echo
|
||||||
|
if [ "$MCP_ENABLED" = true ]; then
|
||||||
|
echo " Claude Desktop config:"
|
||||||
|
echo " {\"mcpServers\":{\"bambu-run\":{\"url\":\"http://${PI_IP:-localhost}:8808/sse\"}}}"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
echo " Services auto-start on boot. Safe to close SSH."
|
echo " Services auto-start on boot. Safe to close SSH."
|
||||||
echo
|
echo
|
||||||
|
|||||||
Reference in New Issue
Block a user