published = 0; foreach ([$this->scanOrders(...), $this->scanPrints(...), $this->scanFilament(...)] as $detector) { try { $detector(); } catch (Throwable $exception) { report($exception); } } return $this->published; } private function scanOrders(): void { $connection = EtsyConnection::first(); if (! $connection || ! $this->etsy->configured()) { return; } $receipts = $this->etsy->receipts($connection)['results'] ?? []; $previous = AlertState::read('etsy.orders'); $seen = $previous['seen'] ?? []; $stages = $previous['stages'] ?? []; $now = time(); $currentIds = []; foreach ($receipts as $receipt) { $id = (string) ($receipt['receipt_id'] ?? ''); if ($id === '') { continue; } $currentIds[] = $id; $buyer = $receipt['name'] ?? $receipt['buyer_email'] ?? 'a buyer'; $total = $receipt['grandtotal'] ?? []; $value = ($total['currency_code'] ?? 'GBP').' '.number_format((float) ($total['amount'] ?? 0) / max(1, (int) ($total['divisor'] ?? 1)), 2); if ($previous !== null && ! in_array($id, $seen, true)) { $this->publish('admins', 'order', 'New Etsy order #'.$id, $buyer.' · '.$value, 'success', route('shipping')); } $shipBy = $this->shipByTimestamp($receipt); if ($shipBy === null) { continue; } $stage = $now >= $shipBy ? 2 : (($shipBy - $now) <= self::DUE_WINDOW_SECONDS ? 1 : 0); if ($stage > ($stages[$id] ?? 0)) { $when = $this->display->dateTime($shipBy); $this->publish( 'admins', 'shipment', $stage === 2 ? 'Order #'.$id.' is overdue to ship' : 'Order #'.$id.' is due to ship within 24 hours', $buyer.' · ship by '.$when, $stage === 2 ? 'danger' : 'warning', route('shipping'), ); $stages[$id] = $stage; } } AlertState::write('etsy.orders', [ 'seen' => array_slice(array_values(array_unique([...$seen, ...$currentIds])), -self::SEEN_ORDER_LIMIT), 'stages' => array_intersect_key($stages, array_flip($currentIds)), ]); } /** @param array $receipt */ private function shipByTimestamp(array $receipt): ?int { $dates = collect($receipt['transactions'] ?? []) ->map(fn ($transaction): int => (int) ($transaction['expected_ship_date'] ?? 0)) ->filter(fn (int $date): bool => $date > 0); return $dates->isEmpty() ? null : $dates->min(); } private function scanPrints(): void { if (! $this->bambuddy->configured()) { return; } foreach ($this->bambuddy->printers() as $printer) { $live = $printer['live'] ?? []; if ($live === [] || ($live['connected'] ?? true) === false) { continue; } $this->scanPrinter((int) $printer['id'], (string) ($printer['name'] ?? 'Printer'), $live); } } /** @param array $live */ private function scanPrinter(int $id, string $name, array $live): void { $key = 'print.'.$id; $last = AlertState::read($key); $state = $last ?? ['active' => false, 'job' => null, 'progress' => 0.0, 'notified' => [], 'errors' => null, 'failed' => false]; $status = strtoupper((string) ($live['state'] ?? '')); $job = (string) ($live['subtask_name'] ?? $live['current_print'] ?? ''); $progress = (float) ($live['progress'] ?? 0); $label = $job !== '' ? $name.' · '.$job : $name; $url = route('printers.index'); if (in_array($status, ['RUNNING', 'PRINTING', 'PREPARE', 'PAUSE'], true)) { if (! $state['active'] || $state['job'] !== $job || $progress < $state['progress'] - 10) { $state['notified'] = $last === null ? array_values(array_filter(self::PRINT_MILESTONES, fn (int $milestone): bool => $progress >= $milestone)) : []; $state['failed'] = false; } $crossed = array_values(array_filter(self::PRINT_MILESTONES, fn (int $milestone): bool => $progress >= $milestone && ! in_array($milestone, $state['notified'], true))); if ($crossed !== []) { $this->publish('everyone', 'print', $label.' is '.max($crossed).'% complete', $this->remaining($live), 'info', $url); $state['notified'] = [...$state['notified'], ...$crossed]; } $state['active'] = true; $state['job'] = $job; $state['progress'] = $progress; } elseif ($status === 'FINISH') { if ($state['active']) { $this->publish('everyone', 'print', $label.' is 100% complete', 'The print has finished.', 'success', $url); } $state['active'] = false; $state['progress'] = 0.0; } elseif (in_array($status, ['FAILED', 'FAIL'], true)) { if (! $state['failed'] && $last !== null) { $this->publish('everyone', 'print-error', $label.' failed', 'The print stopped at '.round($state['progress']).'%.', 'danger', $url); } $state['failed'] = true; $state['active'] = false; $state['progress'] = 0.0; } else { if ($state['active']) { $this->publish('everyone', 'print-error', $label.' stopped early', 'The print ended at '.round($state['progress']).'%.', 'warning', $url); } $state['active'] = false; $state['progress'] = 0.0; } $errors = is_array($live['hms_errors'] ?? null) ? $live['hms_errors'] : []; $errorHash = $errors === [] ? null : md5((string) json_encode($errors)); if ($errorHash !== null && $errorHash !== $state['errors']) { $this->publish('everyone', 'print-error', $name.' reports '.count($errors).' alert'.(count($errors) === 1 ? '' : 's'), (string) ($errors[0]['description'] ?? 'Open BamBuddy for details.'), 'danger', $url); } $state['errors'] = $errorHash; AlertState::write($key, $state); } /** @param array $live */ private function remaining(array $live): string { $minutes = (int) ($live['remaining_time'] ?? 0); return $minutes > 0 ? $this->display->duration($minutes * 60).' remaining' : 'Print in progress.'; } private function scanFilament(): void { if (! $this->bambuddy->configured()) { return; } $state = AlertState::read('filament.low') ?? ['alerted' => []]; $alerted = $state['alerted']; foreach ($this->bambuddy->spools() as $spool) { $capacity = (float) ($spool['label_weight'] ?? 0); if (filled($spool['archived_at'] ?? null) || $capacity <= 0) { continue; } $id = (string) ($spool['id'] ?? ''); $remaining = max(0.0, $capacity - (float) ($spool['weight_used'] ?? 0)); $percent = $remaining / $capacity * 100; if ($percent < self::LOW_FILAMENT_PERCENT && ! in_array($id, $alerted, true)) { $name = trim(($spool['color_name'] ?? '').' '.($spool['material'] ?? 'filament')); $this->publish('everyone', 'filament', 'Low filament: '.$name, trim(($spool['brand'] ?? '').' · '.round($percent).'% (about '.round($remaining).' g) left', ' ·'), 'warning', route('printers.index')); $alerted[] = $id; } elseif ($percent >= self::FILAMENT_REARM_PERCENT) { $alerted = array_values(array_diff($alerted, [$id])); } } AlertState::write('filament.low', ['alerted' => array_values($alerted)]); } /** @param 'admins'|'everyone' $audience */ private function publish(string $audience, string $kind, string $title, string $body, string $level, ?string $url): void { $recipients = User::query()->where('disabled', false) ->when($audience === 'admins', fn ($query) => $query->where('role', 'admin'))->get(); Notification::send($recipients, new DashboardAlert($kind, $title, $body, $level, $url)); $this->published++; } }