From b11538944f2b4ab9f39505cc20ff05f4241fac82 Mon Sep 17 00:00:00 2001 From: Hickmeister <35031453+Hickmeister@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:14:29 +0100 Subject: [PATCH] Add BamBuddy AMS, queue, history and stats to the print farm page Co-Authored-By: Claude Sonnet 5.5 --- .../Commands/RefreshIntegrationCache.php | 3 ++ app/Http/Controllers/BambuddyController.php | 24 +++++++++- app/Services/Bambuddy.php | 33 +++++++++++++ app/Services/Display.php | 11 +++++ resources/views/printers/index.blade.php | 21 ++++++++- tests/Feature/BambuddyPageTest.php | 46 +++++++++++++++++++ 6 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/BambuddyPageTest.php diff --git a/app/Console/Commands/RefreshIntegrationCache.php b/app/Console/Commands/RefreshIntegrationCache.php index 36c545d..a76e6e5 100644 --- a/app/Console/Commands/RefreshIntegrationCache.php +++ b/app/Console/Commands/RefreshIntegrationCache.php @@ -36,6 +36,9 @@ class RefreshIntegrationCache extends Command if ($bambuddy->configured()) { $this->refresh('bambuddy printers', fn () => $bambuddy->printers()); + $this->refresh('bambuddy queue', fn () => $bambuddy->queue()); + $this->refresh('bambuddy recent prints', fn () => $bambuddy->recentPrints()); + $this->refresh('bambuddy stats', fn () => $bambuddy->stats()); } if ($dryer->configured()) { diff --git a/app/Http/Controllers/BambuddyController.php b/app/Http/Controllers/BambuddyController.php index deee15f..e977bcf 100644 --- a/app/Http/Controllers/BambuddyController.php +++ b/app/Http/Controllers/BambuddyController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Services\Bambuddy; +use Closure; use Illuminate\View\View; use Throwable; @@ -20,6 +21,27 @@ class BambuddyController extends Controller $integrationError = 'BamBuddy is unavailable. Check its API key and connection.'; } - return view('printers.index', compact('printers', 'integrationError') + ['bambuddyUrl' => $bambuddy->url()]); + $queue = $printers === [] ? [] : $this->optional(fn (): array => $bambuddy->queue()); + $recentPrints = $printers === [] ? [] : $this->optional(fn (): array => $bambuddy->recentPrints()); + $stats = $printers === [] ? [] : $this->optional(fn (): array => $bambuddy->stats()); + + return view('printers.index', compact('printers', 'integrationError', 'queue', 'recentPrints', 'stats') + ['bambuddyUrl' => $bambuddy->url()]); + } + + /** + * Load secondary page data without letting a failure hide the printers. + * + * @param Closure(): array $load + * @return array + */ + private function optional(Closure $load): array + { + try { + return $load(); + } catch (Throwable $exception) { + report($exception); + + return []; + } } } diff --git a/app/Services/Bambuddy.php b/app/Services/Bambuddy.php index ff8aedc..c79b8be 100644 --- a/app/Services/Bambuddy.php +++ b/app/Services/Bambuddy.php @@ -29,6 +29,39 @@ class Bambuddy return $this->cache->remember('bambuddy.printers.'.md5($this->url()), 45, fn (): array => $this->fetchPrinters()); } + /** @return array> Jobs still waiting or running in the BamBuddy queue. */ + public function queue(): array + { + return $this->cache->remember('bambuddy.queue.'.md5($this->url()), 45, fn (): array => collect($this->get('queue/')) + ->filter(fn ($item): bool => is_array($item) && empty($item['completed_at']) && in_array($item['status'] ?? 'pending', ['pending', 'printing', 'queued', 'waiting'], true)) + ->sortBy('position')->values()->all()); + } + + /** @return array> */ + public function recentPrints(int $limit = 10): array + { + return $this->cache->remember('bambuddy.archives.'.md5($this->url()).'.'.$limit, 120, fn (): array => collect($this->get('archives/', ['limit' => $limit])) + ->filter(fn ($archive): bool => is_array($archive))->values()->all()); + } + + /** @return array */ + public function stats(): array + { + return $this->cache->remember('bambuddy.stats.'.md5($this->url()), 300, fn (): array => $this->get('archives/stats')); + } + + /** @param array $query */ + private function get(string $path, array $query = []): array + { + if (! $this->configured()) { + throw new RuntimeException('BamBuddy API key is not configured.'); + } + + return Http::withHeaders(['X-API-Key' => $this->settings->for('bambuddy')['api_key']]) + ->acceptJson()->connectTimeout(3)->timeout(8) + ->get(rtrim($this->url(), '/').'/api/v1/'.ltrim($path, '/'), $query)->throw()->json() ?? []; + } + private function fetchPrinters(): array { $url = rtrim($this->url(), '/').'/api/v1/printers'; diff --git a/app/Services/Display.php b/app/Services/Display.php index dd559aa..6571e63 100644 --- a/app/Services/Display.php +++ b/app/Services/Display.php @@ -93,6 +93,17 @@ class Display )); } + /** Format a number of seconds as a short duration such as "2h 1m". */ + public function duration(int|float|null $seconds): string + { + if ($seconds === null || $seconds <= 0) { + return '—'; + } + $minutes = (int) round($seconds / 60); + + return $minutes >= 60 ? intdiv($minutes, 60).'h '.($minutes % 60).'m' : $minutes.'m'; + } + /** Format an API date or date-time as a readable string, dropping the time when it is midnight. */ public function dateTime(string|int|null $value): string { diff --git a/resources/views/printers/index.blade.php b/resources/views/printers/index.blade.php index e581836..76f1fd7 100644 --- a/resources/views/printers/index.blade.php +++ b/resources/views/printers/index.blade.php @@ -1,15 +1,32 @@ @extends('layouts.app') @section('title', 'Print farm') @section('content') +@php $display = app(\App\Services\Display::class); @endphp

Production

BamBuddy printers

Live state, progress and temperatures from the workshop.

Open BamBuddy ↗
@if($integrationError)@endif
@forelse($printers as $printer) @php $live = $printer['live'] ?? []; $progress = min(100, max(0, (float) ($live['progress'] ?? 0))); $printerErrors = is_array($live['hms_errors'] ?? null) ? $live['hms_errors'] : []; @endphp

{{ $printer['name'] ?? 'Printer' }}

{{ $printer['model'] ?? 'Bambu printer' }}

($live['connected'] ?? false), 'bg-amber-500/10 text-amber-200' => ! ($live['connected'] ?? false)])>{{ $live['state'] ?? $printer['status'] ?? 'Unavailable' }}
-

Current job

{{ $live['current_print'] ?? $live['subtask_name'] ?? 'No active job' }}

{{ number_format($progress, 0) }}%
+

Current job

{{ $live['current_print'] ?? $live['subtask_name'] ?? 'No active job' }}

{{ number_format($progress, 0) }}%
@if(! empty($live['total_layers']))

Layer {{ $live['layer_num'] ?? 0 }} of {{ $live['total_layers'] }}

@endif
-
Nozzle
{{ isset($live['temperatures']['nozzle']) ? $live['temperatures']['nozzle'].'°C' : '—' }}
Bed
{{ isset($live['temperatures']['bed']) ? $live['temperatures']['bed'].'°C' : '—' }}
Remaining
{{ isset($live['remaining_time']) ? $live['remaining_time'].' min' : '—' }}
+
Nozzle
{{ isset($live['temperatures']['nozzle']) ? $live['temperatures']['nozzle'].'°C' : '—' }}
Bed
{{ isset($live['temperatures']['bed']) ? $live['temperatures']['bed'].'°C' : '—' }}
Remaining
{{ isset($live['remaining_time']) ? $display->duration($live['remaining_time'] * 60) : '—' }}
+ @foreach(($live['ams'] ?? []) as $unit) +
{{ ($unit['is_ams_ht'] ?? false) ? 'AMS HT' : 'AMS' }} {{ ($unit['id'] ?? 0) + 1 }}@if(isset($unit['humidity']))Humidity {{ $unit['humidity'] }}%@endif @if(isset($unit['temp'])) · {{ $unit['temp'] }}°C @endif
+
@foreach(($unit['tray'] ?? []) as $tray)@php $loaded = ($tray['exists'] ?? false) && filled($tray['tray_type'] ?? null); $colour = preg_match('/^[0-9A-Fa-f]{6}/', (string) ($tray['tray_color'] ?? '')) ? '#'.substr($tray['tray_color'], 0, 6) : '#1e293b'; $remain = (int) ($tray['remain'] ?? 0); @endphp +
@if($loaded)

{{ $tray['tray_type'] }}

{{ $remain > 0 ? $remain.'%' : '—' }}

@else

Empty

@endif
@endforeach
+ @endforeach @if(count($printerErrors))

{{ count($printerErrors) }} printer alert{{ count($printerErrors) === 1 ? '' : 's' }} · {{ $printerErrors[0]['description'] ?? 'Open BamBuddy for details' }}

@endif
@empty
No printers returned by BamBuddy.
@endforelse
+ +@if(! $integrationError) +

Print queue

Jobs waiting for a printer.

{{ count($queue) }} queued
+ @forelse($queue as $job)

{{ $job['archive_name'] ?? $job['library_file_name'] ?? 'Print job' }}

{{ $job['printer_name'] ?? $job['target_model'] ?? 'Any printer' }}@if(! empty($job['filament_type'])) · {{ $job['filament_type'] }}@endif @if(! empty($job['print_time_seconds'])) · {{ $display->duration($job['print_time_seconds']) }}@endif

{{ $job['waiting_reason'] ?? ucfirst((string) ($job['status'] ?? 'pending')) }}
+ @empty

The queue is empty.

@endforelse
+@if($stats)
@foreach([['Total prints', $stats['total_prints'] ?? 0], ['Successful', $stats['successful_prints'] ?? 0], ['Failed', $stats['failed_prints'] ?? 0], ['Print time', number_format((float) ($stats['total_print_time_hours'] ?? 0), 1).' h'], ['Filament used', number_format((float) ($stats['total_filament_grams'] ?? 0) / 1000, 2).' kg']] as [$label, $value])

{{ $label }}

{{ $value }}

@endforeach
@endif +

Recent prints

Latest archived jobs from BamBuddy.

+ + @forelse($recentPrints as $archive)@php $status = (string) ($archive['status'] ?? 'unknown'); @endphp + @empty@endforelse
PrintPrinterStatusFilamentDurationCompleted
{{ $archive['print_name'] ?? $archive['filename'] ?? 'Print' }}{{ collect($printers)->firstWhere('id', $archive['printer_id'] ?? null)['name'] ?? '—' }} $status === 'completed', 'bg-rose-500/10 text-rose-300' => $status === 'failed', 'bg-amber-500/10 text-amber-200' => ! in_array($status, ['completed', 'failed'], true)])>{{ ucfirst($status) }}{{ $archive['filament_type'] ?? '—' }}@if(! empty($archive['filament_used_grams'])) · {{ number_format((float) $archive['filament_used_grams'], 0) }} g @endif{{ $display->duration($archive['actual_time_seconds'] ?? $archive['print_time_seconds'] ?? null) }}{{ $display->dateTime($archive['completed_at'] ?? $archive['started_at'] ?? null) }}
No archived prints yet.
+@endif @endsection diff --git a/tests/Feature/BambuddyPageTest.php b/tests/Feature/BambuddyPageTest.php new file mode 100644 index 0000000..8b89455 --- /dev/null +++ b/tests/Feature/BambuddyPageTest.php @@ -0,0 +1,46 @@ + Http::response([ + 'connected' => true, 'state' => 'RUNNING', 'progress' => 40.0, 'remaining_time' => 125, + 'layer_num' => 52, 'total_layers' => 130, 'current_print' => 'Nozzle_Holder', + 'temperatures' => ['nozzle' => 260.0, 'bed' => 90.0], + 'ams' => [['id' => 0, 'humidity' => 40, 'temp' => 27.1, 'is_ams_ht' => false, 'tray' => [ + ['id' => 0, 'tray_color' => '489FDFFF', 'tray_type' => 'ABS', 'remain' => 36, 'exists' => true], + ['id' => 1, 'tray_color' => null, 'tray_type' => null, 'remain' => 0, 'exists' => false], + ]]], + ]), + 'https://3d.example.test/api/v1/printers' => Http::response([['id' => 1, 'name' => 'Printy Boii 2', 'model' => 'X1C']]), + 'https://3d.example.test/api/v1/queue/' => Http::response([ + ['id' => 5, 'status' => 'pending', 'position' => 1, 'archive_name' => 'Queued_Bracket', 'completed_at' => null], + ['id' => 4, 'status' => 'completed', 'position' => 0, 'archive_name' => 'Old_Job', 'completed_at' => '2026-10-01T10:00:00'], + ]), + 'https://3d.example.test/api/v1/archives/stats' => Http::response(['total_prints' => 36, 'successful_prints' => 32, 'failed_prints' => 4, 'total_print_time_hours' => 81.5, 'total_filament_grams' => 1937.1]), + 'https://3d.example.test/api/v1/archives/*' => Http::response([[ + 'id' => 36, 'printer_id' => 1, 'print_name' => 'Finished_Vase', 'status' => 'completed', 'filament_type' => 'ABS', + 'filament_used_grams' => 49.94, 'actual_time_seconds' => 7265, 'completed_at' => '2026-10-11T09:13:01.375546', + ]]), + ]); + config()->set('services.bambuddy', ['url' => 'https://3d.example.test', 'api_key' => 'key']); + $admin = User::factory()->create(['role' => 'admin']); + + $this->actingAs($admin)->get(route('printers.index'))->assertOk() + ->assertSee('Layer 52 of 130')->assertSee('2h 5m')->assertSee('Humidity 40%')->assertSee('Empty') + ->assertSee('Queued_Bracket')->assertDontSee('Old_Job')->assertSee('1 queued') + ->assertSee('Finished_Vase')->assertSee('2h 1m')->assertSee('Sun 11 Oct 2026, 09:13')->assertSee('81.5 h'); + } +}