From aa86766800a3d6773e56a1345c624214a966b8ed Mon Sep 17 00:00:00 2001 From: rendygaafk Date: Sat, 3 May 2025 14:48:44 +0700 Subject: [PATCH] riwayat schedule dan manual --- app/Events/BellRingEvent.php | 20 + app/Http/Controllers/API/BellController.php | 50 + .../Controllers/AnnouncementController.php | 514 ++++---- .../Controllers/BellHistoryController.php | 44 + app/Http/Controllers/belController.php | 600 +++++----- app/Models/Announcement.php | 83 +- app/Models/BellHistory.php | 40 +- app/Models/Ruangan.php | 37 +- app/Services/MqttService.php | 442 +++---- config/mqtt.php | 28 +- config/services.php | 6 - ...2025_04_21_051528_create_ruangan_table.php | 7 +- ...4_22_135609_create_announcements_table.php | 10 +- ...04_24_203925_create_bell_history_table.php | 52 +- database/seeders/DatabaseSeeder.php | 3 +- database/seeders/RuanganSeeder.php | 48 - resources/css/app.css | 65 +- .../admin/announcement/history.blade.php | 348 ++++-- .../views/admin/announcement/index.blade.php | 1041 +++++++---------- resources/views/admin/bel/history.blade.php | 431 ++++--- resources/views/admin/bel/index.blade.php | 772 ++---------- .../admin/bel/partials/empty-state.blade.php | 15 + .../admin/bel/partials/scripts.blade.php | 550 +++++++++ .../admin/bel/partials/status-card.blade.php | 14 + .../bel/partials/today-schedules.blade.php | 36 + routes/api.php | 6 + routes/web.php | 10 +- 27 files changed, 2792 insertions(+), 2480 deletions(-) create mode 100644 app/Events/BellRingEvent.php create mode 100644 app/Http/Controllers/API/BellController.php create mode 100644 app/Http/Controllers/BellHistoryController.php delete mode 100644 database/seeders/RuanganSeeder.php create mode 100644 resources/views/admin/bel/partials/empty-state.blade.php create mode 100644 resources/views/admin/bel/partials/scripts.blade.php create mode 100644 resources/views/admin/bel/partials/status-card.blade.php create mode 100644 resources/views/admin/bel/partials/today-schedules.blade.php diff --git a/app/Events/BellRingEvent.php b/app/Events/BellRingEvent.php new file mode 100644 index 0000000..1dc3270 --- /dev/null +++ b/app/Events/BellRingEvent.php @@ -0,0 +1,20 @@ +data = $data; + } + + public function broadcastOn() { + return new Channel('bell-channel'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/API/BellController.php b/app/Http/Controllers/API/BellController.php new file mode 100644 index 0000000..c8ef7a5 --- /dev/null +++ b/app/Http/Controllers/API/BellController.php @@ -0,0 +1,50 @@ +validate([ + 'hari' => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Minggu', + 'waktu' => 'required|date_format:H:i:s', + 'file_number' => 'required|string|size:4', + 'volume' => 'sometimes|integer|min:0|max:30', + 'repeat' => 'sometimes|integer|min:1|max:5' + ]); + + // Tambahkan trigger_type secara otomatis + $validated['trigger_type'] = 'schedule'; + $validated['ring_time'] = now(); + + $history = BellHistory::create($validated); + + return response()->json([ + 'success' => true, + 'data' => $history + ], 201); + } + + /** + * Mengambil data history untuk ESP32 (jika diperlukan) + */ + public function getHistory(Request $request) + { + $histories = BellHistory::orderBy('ring_time', 'desc') + ->limit(50) + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $histories + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/AnnouncementController.php b/app/Http/Controllers/AnnouncementController.php index c677d6f..d4628c9 100644 --- a/app/Http/Controllers/AnnouncementController.php +++ b/app/Http/Controllers/AnnouncementController.php @@ -4,12 +4,13 @@ use App\Models\Announcement; use App\Models\Ruangan; +use App\Services\MqttService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; -use PhpMqtt\Client\Facades\MQTT; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Storage; +use Carbon\Carbon; class AnnouncementController extends Controller { @@ -17,36 +18,89 @@ class AnnouncementController extends Controller const MODE_REGULER = 'reguler'; const MODE_TTS = 'tts'; + // Relay constants + const RELAY_ON = 'ON'; + const RELAY_OFF = 'OFF'; + // TTS API constants const TTS_API_URL = 'http://api.voicerss.org/'; const TTS_API_KEY = '90927de8275148d79080facd20fb486c'; const TTS_DEFAULT_VOICE = 'id-id'; - const TTS_DEFAULT_SPEED = 0; // -10 to 10 + const TTS_DEFAULT_SPEED = 0; const TTS_DEFAULT_FORMAT = 'wav'; - /** - * Show the announcement form - */ + protected $mqttService; + protected $mqttConfig; + + public function __construct(MqttService $mqttService) + { + $this->mqttService = $mqttService; + $this->mqttConfig = config('mqtt'); + $this->initializeMqttSubscriptions(); + } + + protected function initializeMqttSubscriptions() + { + try { + $this->mqttService->subscribe( + $this->mqttConfig['topics']['responses']['announcement_ack'], + function (string $topic, string $message) { + $this->handleAnnouncementAck($message); + } + ); + + $this->mqttService->subscribe( + $this->mqttConfig['topics']['responses']['announcement_error'], + function (string $topic, string $message) { + $this->handleAnnouncementError($message); + } + ); + + $this->mqttService->subscribe( + $this->mqttConfig['topics']['responses']['relay_status'], + function (string $topic, string $message) { + $this->handleRelayStatusUpdate($message); + } + ); + } catch (\Exception $e) { + Log::error('MQTT Subscription Error: ' . $e->getMessage()); + } + } + public function index() { $ruangan = Ruangan::with(['kelas', 'jurusan'])->get(); - $announcements = Announcement::with(['user', 'ruangans']) + $announcements = Announcement::with(['ruangans']) ->latest() ->paginate(10); + try { + $mqttStatus = $this->mqttService->isConnected() ? 'Connected' : 'Disconnected'; + } catch (\Exception $e) { + $mqttStatus = 'Disconnected'; + Log::error('MQTT check failed: ' . $e->getMessage()); + } + return view('admin.announcement.index', [ - 'ruangan' => $ruangan, + 'ruangans' => $ruangan, 'announcements' => $announcements, - 'modes' => [self::MODE_REGULER, self::MODE_TTS] + 'modes' => [self::MODE_REGULER, self::MODE_TTS], + 'relayStates' => [self::RELAY_ON, self::RELAY_OFF], + 'mqttStatus' => $mqttStatus ]); } - /** - * Handle the announcement request - */ public function store(Request $request) { - $validator = $this->validateRequest($request); + $validator = Validator::make($request->all(), [ + 'mode' => 'required|in:reguler,tts', + 'ruangans' => 'required|array', + 'ruangans.*' => 'exists:ruangan,id', + 'relay_action' => 'required_if:mode,reguler|in:ON,OFF', + 'tts_text' => 'required_if:mode,tts|string|max:1000', + 'tts_voice' => 'required_if:mode,tts', + 'tts_speed' => 'required_if:mode,tts|integer|min:-10|max:10', + ]); if ($validator->fails()) { return redirect()->back() @@ -54,287 +108,249 @@ public function store(Request $request) ->withInput(); } - $mode = $request->input('mode'); - try { - if ($mode === self::MODE_REGULER) { - $this->handleRegularAnnouncement($request); - $message = 'Pengumuman reguler berhasil dikirim ke ruangan terpilih.'; - } elseif ($mode === self::MODE_TTS) { - $this->handleTTSAnnouncement($request); - $message = 'Pengumuman TTS berhasil diproses dan dikirim.'; + $announcement = new Announcement(); + $announcement->mode = $request->mode; + + if ($request->mode === self::MODE_REGULER) { + $announcement->message = $request->relay_action === self::RELAY_ON + ? 'Aktivasi Relay Ruangan' + : 'Deaktivasi Relay Ruangan'; + $announcement->is_active = $request->relay_action === self::RELAY_ON; + $announcement->relay_state = $request->relay_action; + } else { + $audioContent = $this->generateTTS( + $request->tts_text, + $request->tts_voice, + $request->tts_speed + ); + + if (!$audioContent) { + throw new \Exception('Failed to generate TTS audio'); + } + + $fileName = 'tts/' . now()->format('YmdHis') . '.wav'; + Storage::disk('public')->put($fileName, $audioContent); + + $announcement->message = $request->tts_text; + $announcement->audio_path = $fileName; + $announcement->voice = $request->tts_voice; + $announcement->speed = $request->tts_speed; + $announcement->relay_state = self::RELAY_OFF; // Default untuk TTS } - return redirect()->route('admin.announcement.index')->with('success', $message); + $announcement->sent_at = now(); + $announcement->status = 'pending'; + + if (!$announcement->save()) { + throw new \Exception('Failed to save announcement'); + } + + $existingRuangan = Ruangan::whereIn('id', $request->ruangans)->pluck('id'); + if ($existingRuangan->count() != count($request->ruangans)) { + throw new \Exception('Some selected ruangan not found'); + } + + $announcement->ruangans()->sync($existingRuangan); + + $this->publishAnnouncement($announcement); + + return redirect()->route('announcement.index') + ->with('success', 'Pengumuman berhasil dikirim'); + } catch (\Exception $e) { - Log::error('Announcement error: ' . $e->getMessage()); + Log::error('Announcement Error: ' . $e->getMessage()); + if (isset($announcement) && $announcement->exists) { + $announcement->delete(); + } return redirect()->back() - ->with('error', 'Terjadi kesalahan saat memproses pengumuman: ' . $e->getMessage()) + ->with('error', 'Gagal: ' . $e->getMessage()) ->withInput(); } } - - /** - * Validate the announcement request - */ - private function validateRequest(Request $request) + protected function publishAnnouncement(Announcement $announcement) { - $rules = [ - 'mode' => 'required|in:reguler,tts', - ]; - - if ($request->input('mode') === self::MODE_REGULER) { - $rules['ruangan'] = 'required|array'; - $rules['ruangan.*'] = 'exists:ruangan,id'; - // Remove message requirement for regular mode - } elseif ($request->input('mode') === self::MODE_TTS) { - $rules['tts_text'] = 'required|string|max:1000'; - $rules['tts_voice'] = 'nullable|string'; - $rules['tts_speed'] = 'nullable|integer|min:-10|max:10'; - $rules['tts_ruangan'] = 'required|array'; - $rules['tts_ruangan.*'] = 'exists:ruangan,id'; - } - - return Validator::make($request->all(), $rules); - } - - /** - * Handle regular announcement - */ - private function handleRegularAnnouncement(Request $request) - { - $ruanganIds = $request->input('ruangan'); - - $ruangan = Ruangan::whereIn('id', $ruanganIds)->get(); - $payload = [ - 'type' => 'reguler', - 'action' => 'activate', // Add action type - 'ruangan' => $ruangan->pluck('nama_ruangan')->toArray(), - 'timestamp' => now()->toDateTimeString() - ]; - - $this->publishToMQTT($payload); - - // Simpan ke database tanpa message - $announcement = Announcement::create([ - 'mode' => self::MODE_REGULER, - 'message' => 'Aktivasi ruangan', // Default message or empty - 'ruangan' => $ruangan->pluck('nama_ruangan')->toArray(), - 'user_id' => auth()->id, - 'sent_at' => now() - ]); - - // Attach ruangan ke pengumuman - $announcement->ruangans()->attach($ruanganIds); - - Log::info('Regular announcement sent', [ + 'mode' => $announcement->mode, 'announcement_id' => $announcement->id, - 'ruangan' => $ruanganIds - ]); - } - - private function handleTTSAnnouncement(Request $request) - { - $text = $request->input('tts_text'); - $ruanganIds = $request->input('tts_ruangan'); - $voice = $request->input('tts_voice', self::TTS_DEFAULT_VOICE); - $speed = $request->input('tts_speed', self::TTS_DEFAULT_SPEED); - - $audioContent = $this->generateTTS($text, $voice, $speed); - - if (!$audioContent) { - throw new \Exception('Gagal menghasilkan audio TTS'); - } - - // Simpan file audio - $fileName = 'tts/' . now()->format('YmdHis') . '.wav'; - Storage::disk('public')->put($fileName, $audioContent); - - $ruangan = Ruangan::whereIn('id', $ruanganIds)->get(); - - $payload = [ - 'type' => 'tts', - 'audio_url' => asset('storage/' . $fileName), - 'ruangan' => $ruangan->pluck('nama_ruangan')->toArray(), + 'ruangans' => $announcement->ruangans->pluck('nama_ruangan')->toArray(), 'timestamp' => now()->toDateTimeString() ]; - - $this->publishToMQTT($payload); - - // Simpan ke database - $announcement = Announcement::create([ - 'mode' => self::MODE_TTS, - 'message' => $text, - 'audio_path' => $fileName, - 'voice' => $voice, - 'speed' => $speed, - 'ruangan' => $ruangan->pluck('nama_ruangan')->toArray(), - 'user_id' => auth()->id, - 'sent_at' => now() - ]); - - // Attach ruangan ke pengumuman - $announcement->ruangans()->attach($ruanganIds); - - Log::info('TTS announcement sent', [ - 'announcement_id' => $announcement->id, // Diubah dari id() ke id - 'ruangan' => $ruanganIds, - 'text' => $text, - 'voice' => $voice, - 'speed' => $speed - ]); + + if ($announcement->mode === self::MODE_REGULER) { + $payload['relay_state'] = $announcement->relay_state; + + // Kirim perintah relay ke masing-masing ruangan + foreach ($announcement->ruangans as $ruangan) { + $topic = $ruangan->mqtt_topic ?? "ruangan/{$ruangan->id}/relay/control"; + + $this->mqttService->publish( + $topic, + json_encode([ + 'state' => $announcement->relay_state, + 'announcement_id' => $announcement->id + ]), + 1 // QoS level + ); + + // Update status relay di database + $ruangan->update(['relay_state' => $announcement->relay_state]); + } + } else { + $payload['message'] = $announcement->message; + $payload['audio_url'] = asset('storage/' . $announcement->audio_path); + $payload['voice'] = $announcement->voice; + $payload['speed'] = $announcement->speed; + } + + // Publis ke topic announcement umum + $this->mqttService->publish( + $this->mqttConfig['topics']['commands']['announcement'], + json_encode($payload), + 1 + ); } - /** - * Generate TTS audio using VoiceRSS API - */ - private function generateTTS($text, $voice, $speed) + protected function generateTTS($text, $voice, $speed) + { + $response = Http::get(self::TTS_API_URL, [ + 'key' => self::TTS_API_KEY, + 'hl' => $voice, + 'src' => $text, + 'r' => $speed, + 'c' => self::TTS_DEFAULT_FORMAT, + 'f' => '8khz_8bit_mono' + ]); + + if ($response->successful()) { + return $response->body(); + } + + Log::error('TTS API Error: ' . $response->body()); + return null; + } + + protected function handleAnnouncementAck(string $message) { try { - $response = Http::get(self::TTS_API_URL, [ - 'key' => self::TTS_API_KEY, - 'hl' => $voice, - 'src' => $text, - 'r' => $speed, - 'c' => self::TTS_DEFAULT_FORMAT, - 'f' => '8khz_8bit_mono' // Lower quality for faster transmission - ]); + $data = json_decode($message, true); + + if (isset($data['announcement_id'])) { + Announcement::where('id', $data['announcement_id']) + ->update(['status' => 'delivered']); + + Log::info('Announcement delivered', $data); + } + } catch (\Exception $e) { + Log::error('ACK Handler Error: ' . $e->getMessage()); + } + } + + protected function handleAnnouncementError(string $message) + { + try { + $data = json_decode($message, true); + + if (isset($data['announcement_id'])) { + Announcement::where('id', $data['announcement_id']) + ->update([ + 'status' => 'failed', + 'error_message' => $data['error'] ?? 'Unknown error' + ]); + + Log::error('Announcement failed', $data); + } + } catch (\Exception $e) { + Log::error('Error Handler Error: ' . $e->getMessage()); + } + } - if ($response->successful()) { - return $response->body(); + protected function handleRelayStatusUpdate(string $message) + { + try { + $data = json_decode($message, true); + + if (isset($data['ruangan_id'], $data['state'])) { + Ruangan::where('id', $data['ruangan_id']) + ->update(['relay_state' => $data['state']]); + + Log::info('Relay status updated', $data); + } + } catch (\Exception $e) { + Log::error('Relay Status Handler Error: ' . $e->getMessage()); + } + } + + public function ttsPreview(Request $request) + { + $validator = Validator::make($request->all(), [ + 'text' => 'required|string|max:1000', + 'voice' => 'required|string', + 'speed' => 'required|integer|min:-10|max:10' + ]); + + if ($validator->fails()) { + return response()->json([ + 'error' => $validator->errors()->first() + ], 400); + } + + try { + $audioContent = $this->generateTTS( + $request->text, + $request->voice, + $request->speed + ); + + if (!$audioContent) { + throw new \Exception('Failed to generate TTS audio'); } - Log::error('TTS API Error: ' . $response->body()); - return null; + $fileName = 'tts/previews/' . uniqid() . '.wav'; + Storage::disk('public')->put($fileName, $audioContent); + + return response()->json([ + 'audio_url' => asset('storage/' . $fileName) + ]); + } catch (\Exception $e) { - Log::error('TTS Generation Error: ' . $e->getMessage()); - return null; + Log::error('TTS Preview Error: ' . $e->getMessage()); + return response()->json([ + 'error' => 'Failed to generate preview' + ], 500); } } - - /** - * Publish message to MQTT broker - */ - private function publishToMQTT(array $payload) - { - try { - $mqtt = MQTT::connection(); - $mqtt->publish('announcement/channel', json_encode($payload), 0); - $mqtt->disconnect(); - } catch (\Exception $e) { - Log::error('MQTT Publish Error: ' . $e->getMessage()); - throw new \Exception('Gagal mengirim pesan ke MQTT broker'); - } - } - - /** - * Get available TTS voices - */ - public function getTTSVoices() - { - return [ - 'id-id' => 'Indonesian', - 'en-us' => 'English (US)', - 'en-gb' => 'English (UK)', - 'ja-jp' => 'Japanese', - 'es-es' => 'Spanish', - 'fr-fr' => 'French', - 'de-de' => 'German' - ]; - } - - /** - * Show announcement history - */ + public function history(Request $request) { $search = $request->input('search'); $mode = $request->input('mode'); + $relayState = $request->input('relay_state'); - $announcements = Announcement::with(['user', 'ruangans']) + $announcements = Announcement::with(['ruangans']) ->when($search, function($query) use ($search) { return $query->where('message', 'like', "%{$search}%") ->orWhereHas('ruangans', function($q) use ($search) { $q->where('nama_ruangan', 'like', "%{$search}%"); - }) - ->orWhereHas('user', function($q) use ($search) { - $q->where('name', 'like', "%{$search}%"); }); }) ->when($mode, function($query) use ($mode) { return $query->where('mode', $mode); }) + ->when($relayState, function($query) use ($relayState) { + return $query->where('relay_state', $relayState); + }) ->latest() ->paginate(10); - return view('announcement.history', [ + return view('admin.announcement.history', [ 'announcements' => $announcements, 'search' => $search, 'mode' => $mode, - 'modes' => [self::MODE_REGULER, self::MODE_TTS] - ]); - } - - /** - * Show announcement detail - */ - public function show(Announcement $announcement) - { - return view('announcement.show', [ - 'announcement' => $announcement->load(['user', 'ruangans']) - ]); - } - - /** - * Delete an announcement - */ - public function destroy(Announcement $announcement) - { - try { - // Hapus file audio jika ada - if ($announcement->audio_path && Storage::disk('public')->exists($announcement->audio_path)) { - Storage::disk('public')->delete($announcement->audio_path); - } - - $announcement->delete(); - - return redirect()->route('announcement.history') - ->with('success', 'Pengumuman berhasil dihapus'); - } catch (\Exception $e) { - Log::error('Error deleting announcement: ' . $e->getMessage()); - return redirect()->back() - ->with('error', 'Gagal menghapus pengumuman: ' . $e->getMessage()); - } - } - - /** - * TTS Preview endpoint - */ - public function ttsPreview(Request $request) - { - $validated = $request->validate([ - 'text' => 'required|string|max:1000', - 'voice' => 'nullable|string', - 'speed' => 'nullable|integer|min:-10|max:10' - ]); - - $audioContent = $this->generateTTS( - $validated['text'], - $validated['voice'] ?? self::TTS_DEFAULT_VOICE, - $validated['speed'] ?? self::TTS_DEFAULT_SPEED - ); - - if (!$audioContent) { - return response()->json(['message' => 'Gagal menghasilkan audio'], 500); - } - - $fileName = 'tts/previews/' . uniqid() . '.wav'; - Storage::disk('public')->put($fileName, $audioContent); - - return response()->json([ - 'audio_url' => asset('storage/' . $fileName) + 'relay_state' => $relayState, + 'modes' => [self::MODE_REGULER, self::MODE_TTS], + 'relayStates' => [self::RELAY_ON, self::RELAY_OFF] ]); } } \ No newline at end of file diff --git a/app/Http/Controllers/BellHistoryController.php b/app/Http/Controllers/BellHistoryController.php new file mode 100644 index 0000000..2ac511a --- /dev/null +++ b/app/Http/Controllers/BellHistoryController.php @@ -0,0 +1,44 @@ +paginate(20); + + return view('admin.bel.history', compact('histories')); + } + + public function filterHistory(Request $request) + { + $query = BellHistory::query(); + + if ($request->has('hari')) { + $query->where('hari', $request->hari); + } + + if ($request->has('trigger_type')) { + $query->where('trigger_type', $request->trigger_type); + } + + $histories = $query->orderBy('ring_time', 'desc') + ->paginate(20); + + return view('admin.bel.history', compact('histories')); + } + + public function destroy($id) + { + $history = BellHistory::findOrFail($id); + $history->delete(); + + return redirect()->route('bel.history.index') + ->with('success', 'History bel berhasil dihapus'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/belController.php b/app/Http/Controllers/belController.php index b325530..86c9467 100644 --- a/app/Http/Controllers/belController.php +++ b/app/Http/Controllers/belController.php @@ -14,71 +14,125 @@ class BelController extends Controller { protected $mqttService; protected $mqttConfig; + protected const DAY_MAP = [ + 'Monday' => 'Senin', + 'Tuesday' => 'Selasa', + 'Wednesday' => 'Rabu', + 'Thursday' => 'Kamis', + 'Friday' => 'Jumat', + 'Saturday' => 'Sabtu', + 'Sunday' => 'Minggu' + ]; + protected const DAY_ORDER = [ + 'Senin' => 1, + 'Selasa' => 2, + 'Rabu' => 3, + 'Kamis' => 4, + 'Jumat' => 5, + 'Sabtu' => 6, + 'Minggu' => 7 + ]; public function __construct(MqttService $mqttService) { $this->mqttService = $mqttService; $this->mqttConfig = config('mqtt'); - + $this->initializeMqttSubscriptions(); } - protected function initializeMqttSubscriptions() + protected function initializeMqttSubscriptions(): void { try { - // Subscribe ke topik status response - $this->mqttService->subscribe( - $this->mqttConfig['topics']['responses']['status'], - function (string $topic, string $message) { - $this->handleStatusResponse($message); - } - ); + $topics = $this->mqttConfig['topics']['responses']; + $events = $this->mqttConfig['topics']['events']; + + $this->mqttService->subscribe($topics['status'], fn($t, $m) => $this->handleStatusResponse($m)); + $this->mqttService->subscribe($topics['ack'], fn($t, $m) => $this->handleAckResponse($m)); + $this->mqttService->subscribe($events['bell_manual'], fn($t, $m) => $this->handleBellEvent($m, 'manual')); + $this->mqttService->subscribe($events['bell_schedule'], fn($t, $m) => $this->handleBellEvent($m, 'schedule')); - // Subscribe ke topik acknowledgment - $this->mqttService->subscribe( - $this->mqttConfig['topics']['responses']['ack'], - function (string $topic, string $message) { - $this->handleAckResponse($message); - } - ); - - // Tambahkan subscribe untuk topik bell ring - $this->mqttService->subscribe( - $this->mqttConfig['topics']['responses']['bell_ring'], - function (string $topic, string $message) { - $this->handleBellRing($message); - } - ); + Log::info('Successfully subscribed to MQTT topics'); } catch (\Exception $e) { Log::error('Failed to initialize MQTT subscriptions: ' . $e->getMessage()); } } - protected function handleStatusResponse(string $message) + protected function handleBellEvent(string $message, string $triggerType): void + { + Log::debug("Processing {$triggerType} bell", ['raw_message' => $message]); + + try { + $data = json_decode($message, true, 512, JSON_THROW_ON_ERROR); + + $requiredFields = ['hari', 'waktu', 'file_number']; + foreach ($requiredFields as $field) { + if (!isset($data[$field])) { + throw new \Exception("Field {$field} tidak ditemukan"); + } + } + + $history = BellHistory::create([ + 'hari' => $data['hari'], + 'waktu' => $this->normalizeWaktu($data['waktu']), + 'file_number' => $data['file_number'], + 'trigger_type' => $triggerType, + 'ring_time' => now(), + 'volume' => $data['volume'] ?? 15, + 'repeat' => $data['repeat'] ?? 1 + ]); + + Log::info("Bell {$triggerType} tersimpan", [ + 'id' => $history->id, + 'hari' => $data['hari'], + 'file' => $data['file_number'] + ]); + } catch (\Exception $e) { + Log::error("Gagal menyimpan bell {$triggerType}", [ + 'error' => $e->getMessage(), + 'message' => $message, + 'trace' => $e->getTraceAsString() + ]); + } + } + + private function normalizeWaktu(?string $time): string + { + if (empty($time)) { + return '00:00:00'; + } + + $parts = explode(':', $time); + $hour = min(23, max(0, (int)($parts[0] ?? 0))); + $minute = min(59, max(0, (int)($parts[1] ?? 0))); + $second = min(59, max(0, (int)($parts[2] ?? 0))); + + return sprintf('%02d:%02d:%02d', $hour, $minute, $second); + } + + protected function handleStatusResponse(string $message): void { try { $data = json_decode($message, true); - // Validasi payload + if (!is_array($data)) { - Log::error('Invalid status data format'); - return; + throw new \Exception('Invalid status data format'); } - // Pastikan semua kunci penting ada + $requiredKeys = ['rtc', 'dfplayer', 'rtc_time', 'last_communication', 'last_sync']; foreach ($requiredKeys as $key) { if (!array_key_exists($key, $data)) { - Log::error("Missing required key in status data: {$key}"); - return; + throw new \Exception("Missing required key: {$key}"); } } - // Simpan data ke database + Status::updateOrCreate( ['id' => 1], [ - 'rtc' => $data['rtc'] ?? false, - 'dfplayer' => $data['dfplayer'] ?? false, - 'rtc_time' => $data['rtc_time'] ?? null, - 'last_communication' => Carbon::createFromTimestamp($data['last_communication'] ?? 0), - 'last_sync' => Carbon::createFromTimestamp($data['last_sync'] ?? 0) + 'rtc' => $data['rtc'], + 'dfplayer' => $data['dfplayer'], + 'rtc_time' => $data['rtc_time'], + 'last_communication' => Carbon::createFromTimestamp($data['last_communication']), + 'last_sync' => Carbon::createFromTimestamp($data['last_sync']) ] ); } catch (\Exception $e) { @@ -86,24 +140,23 @@ protected function handleStatusResponse(string $message) } } - protected function handleAckResponse(string $message) + protected function handleAckResponse(string $message): void { try { $data = json_decode($message, true); - if (isset($data['action'])) { - $action = $data['action']; - $message = $data['message'] ?? ''; - - if ($action === 'sync_ack') { - Status::updateOrCreate( - ['id' => 1], - ['last_sync' => Carbon::now()] - ); - Log::info('Schedule sync acknowledged: ' . $message); - } elseif ($action === 'ring_ack') { - Log::info('Bell ring acknowledged: ' . $message); - } + if (!isset($data['action'])) { + return; + } + + $action = $data['action']; + $message = $data['message'] ?? ''; + + if ($action === 'sync_ack') { + Status::updateOrCreate(['id' => 1], ['last_sync' => Carbon::now()]); + Log::info('Schedule sync acknowledged: ' . $message); + } elseif ($action === 'ring_ack') { + Log::info('Bell ring acknowledged: ' . $message); } } catch (\Exception $e) { Log::error('Error handling ack response: ' . $e->getMessage()); @@ -113,7 +166,6 @@ protected function handleAckResponse(string $message) public function index(Request $request) { try { - // Ambil data utama terlepas dari koneksi MQTT $query = JadwalBel::query(); if ($request->filled('hari')) { @@ -127,42 +179,36 @@ public function index(Request $request) }); } - $schedules = $query->orderBy('hari')->orderBy('waktu')->paginate(10); - $status = Status::firstOrCreate(['id' => 1]); + $today = Carbon::now()->isoFormat('dddd'); + $currentTime = Carbon::now()->format('H:i:s'); - // Jadwal hari ini - $todaySchedules = JadwalBel::where('hari', Carbon::now()->isoFormat('dddd')) - ->orderBy('waktu') - ->get(); - - // Jadwal berikutnya - $nextSchedule = JadwalBel::where('hari', Carbon::now()->isoFormat('dddd')) - ->where('waktu', '>', Carbon::now()->format('H:i:s')) - ->orderBy('waktu') - ->first(); - - // Cek koneksi MQTT tanpa menghentikan eksekusi jika error - try { - $mqttStatus = $this->mqttService->isConnected() ? 'Connected' : 'Disconnected'; - } catch (\Exception $e) { - $mqttStatus = 'Disconnected'; - Log::error('MQTT check failed: ' . $e->getMessage()); - } - return view('admin.bel.index', [ - 'schedules' => $schedules, - 'todaySchedules' => $todaySchedules, - 'nextSchedule' => $nextSchedule, - 'status' => $status, - 'mqttStatus' => $mqttStatus + 'schedules' => $query->orderBy('hari')->orderBy('waktu')->paginate(10), + 'todaySchedules' => JadwalBel::where('hari', $today) + ->orderBy('waktu') + ->get(), + 'nextSchedule' => JadwalBel::where('hari', $today) + ->where('waktu', '>', $currentTime) + ->orderBy('waktu') + ->first(), + 'status' => Status::firstOrCreate(['id' => 1]), + 'mqttStatus' => $this->getMqttStatus() ]); - } catch (\Exception $e) { Log::error('Error in index method: ' . $e->getMessage()); return back()->with('error', 'Terjadi kesalahan saat memuat data jadwal'); } } + protected function getMqttStatus(): string + { + try { + return $this->mqttService->isConnected() ? 'Connected' : 'Disconnected'; + } catch (\Exception $e) { + Log::error('MQTT check failed: ' . $e->getMessage()); + return 'Disconnected'; + } + } public function create() { @@ -178,7 +224,6 @@ public function store(Request $request) try { $schedule = JadwalBel::create($validated); - $this->syncSchedules(); $this->logActivity('Jadwal dibuat', $schedule); @@ -190,18 +235,14 @@ public function store(Request $request) ]); } catch (\Exception $e) { Log::error('Gagal menambah jadwal: ' . $e->getMessage()); - return back() - ->withInput() - ->with('error', 'Gagal menambah jadwal: ' . $e->getMessage()); + return back()->withInput()->with('error', 'Gagal menambah jadwal: ' . $e->getMessage()); } } public function edit($id) { - $schedule = JadwalBel::findOrFail($id); - return view('admin.bel.edit', [ - 'schedule' => $schedule, + 'schedule' => JadwalBel::findOrFail($id), 'days' => JadwalBel::DAYS ]); } @@ -213,7 +254,6 @@ public function update(Request $request, $id) try { $schedule->update($validated); - $this->syncSchedules(); $this->logActivity('Jadwal diperbarui', $schedule); @@ -224,20 +264,16 @@ public function update(Request $request, $id) 'scroll_to' => 'schedule-'.$schedule->id ]); } catch (\Exception $e) { - Log::error('Gagal update jadwal ID '.$schedule->id.': ' . $e->getMessage()); - return back() - ->withInput() - ->with('error', 'Gagal memperbarui jadwal: ' . $e->getMessage()); + Log::error('Gagal update jadwal ID '.$id.': ' . $e->getMessage()); + return back()->withInput()->with('error', 'Gagal memperbarui jadwal: ' . $e->getMessage()); } } public function destroy($id) { - $schedule = JadwalBel::findOrFail($id); - try { + $schedule = JadwalBel::findOrFail($id); $schedule->delete(); - $this->syncSchedules(); $this->logActivity('Jadwal dihapus', $schedule); @@ -245,9 +281,8 @@ public function destroy($id) ->route('bel.index') ->with('success', 'Jadwal berhasil dihapus'); } catch (\Exception $e) { - Log::error('Gagal hapus jadwal ID '.$schedule->id.': ' . $e->getMessage()); - return back() - ->with('error', 'Gagal menghapus jadwal: ' . $e->getMessage()); + Log::error('Gagal hapus jadwal ID '.$id.': ' . $e->getMessage()); + return back()->with('error', 'Gagal menghapus jadwal: ' . $e->getMessage()); } } @@ -262,8 +297,7 @@ public function deleteAll() ->with('success', 'Semua jadwal berhasil dihapus'); } catch (\Exception $e) { Log::error('Gagal hapus semua jadwal: ' . $e->getMessage()); - return back() - ->with('error', 'Gagal menghapus semua jadwal: ' . $e->getMessage()); + return back()->with('error', 'Gagal menghapus semua jadwal: ' . $e->getMessage()); } } @@ -272,30 +306,18 @@ public function toggleStatus($id) try { $schedule = JadwalBel::findOrFail($id); $newStatus = !$schedule->is_active; - $schedule->is_active = $newStatus; - $schedule->save(); + $schedule->update(['is_active' => $newStatus]); - // Return JSON response for AJAX requests - if (request()->wantsJson()) { - return response()->json([ - 'success' => true, - 'message' => 'Status jadwal berhasil diubah', - 'is_active' => $newStatus - ]); - } - - // Fallback for non-AJAX requests - return redirect()->back()->with('success', 'Status jadwal berhasil diubah'); - + return response()->json([ + 'success' => true, + 'message' => 'Status jadwal berhasil diubah', + 'is_active' => $newStatus + ]); } catch (\Exception $e) { - if (request()->wantsJson()) { - return response()->json([ - 'success' => false, - 'message' => 'Gagal mengubah status: ' . $e->getMessage() - ], 500); - } - - return redirect()->back()->with('error', 'Gagal mengubah status'); + return response()->json([ + 'success' => false, + 'message' => 'Gagal mengubah status: ' . $e->getMessage() + ], 500); } } @@ -341,68 +363,70 @@ public function ring(Request $request) { $validated = $request->validate([ 'file_number' => 'required|string|size:4', - 'repeat' => 'sometimes|integer|min:1|max:10', - 'volume' => 'sometimes|integer|min:0|max:30' + 'volume' => 'sometimes|integer|min:1|max:30', ]); - - try { - // Catat ke history terlebih dahulu - BellHistory::create([ - 'hari' => Carbon::now()->isoFormat('dddd'), - 'waktu' => Carbon::now()->format('H:i:s'), - 'file_number' => $validated['file_number'], - 'trigger_type' => BellHistory::TRIGGER_MANUAL, - 'ring_time' => Carbon::now(), - 'volume' => $validated['volume'] ?? 15, - 'repeat' => $validated['repeat'] ?? 1 - ]); - // Kirim perintah ke MQTT - $message = json_encode([ - 'action' => 'ring', - 'timestamp' => Carbon::now()->toDateTimeString(), + // Konversi nama hari ke format enum Indonesia + $dayMap = [ + 'Monday' => 'Senin', + 'Tuesday' => 'Selasa', + 'Wednesday' => 'Rabu', + 'Thursday' => 'Kamis', + 'Friday' => 'Jumat', + 'Saturday' => 'Sabtu', + 'Sunday' => 'Minggu', + ]; + $hari = $dayMap[now()->format('l')]; // format('l') = nama hari dalam Bahasa Inggris + + try { + $bellData = [ + 'hari' => $hari, + 'waktu' => now()->format('H:i:s'), 'file_number' => $validated['file_number'], - 'repeat' => $validated['repeat'] ?? 1, - 'volume' => $validated['volume'] ?? 15, - 'trigger_type' => BellHistory::TRIGGER_MANUAL - ]); - + 'volume' => $validated['volume'], + ]; + + BellHistory::create(array_merge($bellData, [ + 'trigger_type' => 'manual', + 'ring_time' => now() + ])); + $this->mqttService->publish( $this->mqttConfig['topics']['commands']['ring'], - $message, + json_encode($validated), 1 ); - + return response()->json([ 'success' => true, - 'message' => 'Perintah bel berhasil dikirim', - 'data' => [ - 'file_number' => $validated['file_number'], - 'timestamp' => Carbon::now()->toDateTimeString() - ] + 'message' => 'Bel manual berhasil diaktifkan', + 'data' => $bellData ]); } catch (\Exception $e) { Log::error('Gagal mengirim bel manual: ' . $e->getMessage()); return response()->json([ 'success' => false, - 'message' => 'Gagal mengirim perintah bel: ' . $e->getMessage() + 'message' => 'Gagal mengaktifkan bel: ' . $e->getMessage() ], 500); } } + + public function status() { try { - $message = json_encode([ - 'action' => 'get_status', - 'timestamp' => Carbon::now()->toDateTimeString() - ]); $this->mqttService->publish( $this->mqttConfig['topics']['commands']['status'], - $message, + json_encode([ + 'action' => 'get_status', + 'timestamp' => Carbon::now()->toDateTimeString() + ]), 1 ); + $status = Status::firstOrCreate(['id' => 1]); + return response()->json([ 'success' => true, 'message' => 'Permintaan status terkirim', @@ -413,7 +437,7 @@ public function status() 'last_communication' => $status->last_communication, 'last_sync' => $status->last_sync, 'mqtt_status' => $this->mqttService->isConnected(), - 'status' => $status->status ?? 'unknown' // Default value jika kolom kosong + 'status' => $status->status ?? 'unknown' ] ]); } catch (\Exception $e) { @@ -425,58 +449,28 @@ public function status() } } - protected function syncJadwalToEsp($schedules) - { - try { - $message = json_encode([ - 'action' => 'sync', - 'timestamp' => Carbon::now()->toDateTimeString(), - 'schedules' => $schedules - ]); - - $this->mqttService->publish( - $this->mqttConfig['topics']['commands']['sync'], - $message, - 1 - ); - - Log::info("Sync schedules sent to MQTT", ['count' => count($schedules)]); - } catch (\Exception $e) { - Log::error('Error syncing schedules to MQTT: ' . $e->getMessage()); - } - } - public function syncSchedule() { try { $schedules = JadwalBel::active() ->get() - ->map(function ($item) { - return [ - 'hari' => $item->hari, - 'waktu' => Carbon::parse($item->waktu)->format('H:i'), // Pastikan format waktu sesuai - 'file_number' => $item->file_number - ]; - }); - - $message = json_encode([ - 'action' => 'sync', - 'timestamp' => Carbon::now()->toDateTimeString(), - 'schedules' => $schedules - ]); - - Log::info("Sync message sent to MQTT", ['message' => $message]); // Debugging + ->map(fn($item) => [ + 'hari' => $item->hari, + 'waktu' => Carbon::parse($item->waktu)->format('H:i'), + 'file_number' => $item->file_number + ]); $this->mqttService->publish( $this->mqttConfig['topics']['commands']['sync'], - $message, + json_encode([ + 'action' => 'sync', + 'timestamp' => Carbon::now()->toDateTimeString(), + 'schedules' => $schedules + ]), 1 ); - Status::updateOrCreate( - ['id' => 1], - ['last_sync' => Carbon::now()] - ); + Status::updateOrCreate(['id' => 1], ['last_sync' => Carbon::now()]); return response()->json([ 'success' => true, @@ -495,20 +489,26 @@ public function syncSchedule() } } - protected function syncSchedules() + protected function syncSchedules(): void { try { $schedules = JadwalBel::active() ->get() - ->map(function ($item) { - return [ - 'hari' => $item->hari, - 'waktu' => Carbon::parse($item->waktu)->format('H:i:s'), - 'file_number' => $item->file_number - ]; - }); + ->map(fn($item) => [ + 'hari' => $item->hari, + 'waktu' => Carbon::parse($item->waktu)->format('H:i:s'), + 'file_number' => $item->file_number + ]); - $this->syncJadwalToEsp($schedules); + $this->mqttService->publish( + $this->mqttConfig['topics']['commands']['sync'], + json_encode([ + 'action' => 'sync', + 'timestamp' => Carbon::now()->toDateTimeString(), + 'schedules' => $schedules + ]), + 1 + ); Log::info("Auto sync: " . count($schedules) . " jadwal"); } catch (\Exception $e) { @@ -518,36 +518,11 @@ protected function syncSchedules() public function getNextSchedule() { - $now = now(); - - // Mapping for English to Indonesian day names - $dayMap = [ - 'Monday' => 'Senin', - 'Tuesday' => 'Selasa', - 'Wednesday' => 'Rabu', - 'Thursday' => 'Kamis', - 'Friday' => 'Jumat', - 'Saturday' => 'Sabtu', - 'Sunday' => 'Minggu' - ]; - - $currentDayEnglish = $now->format('l'); // Get English day name (e.g. "Saturday") - $currentDay = $dayMap[$currentDayEnglish] ?? $currentDayEnglish; // Convert to Indonesian - + $now = Carbon::now(); + $currentDay = self::DAY_MAP[$now->format('l')] ?? $now->format('l'); $currentTime = $now->format('H:i:s'); - // Correct day order (Monday-Sunday) - $dayOrder = [ - 'Senin' => 1, - 'Selasa' => 2, - 'Rabu' => 3, - 'Kamis' => 4, - 'Jumat' => 5, - 'Sabtu' => 6, - 'Minggu' => 7 - ]; - - // 1. First try to find today's upcoming ACTIVE schedules + // 1. Try to find today's upcoming ACTIVE schedules $todaysSchedule = JadwalBel::where('is_active', true) ->where('hari', $currentDay) ->where('waktu', '>', $currentTime) @@ -564,39 +539,33 @@ public function getNextSchedule() ->orderBy('waktu') ->get(); - $currentDayValue = $dayOrder[$currentDay] ?? 0; + $currentDayValue = self::DAY_ORDER[$currentDay] ?? 0; $closestSchedule = null; - $minDayDiff = 8; // More than 7 days + $minDayDiff = 8; foreach ($allSchedules as $schedule) { - $scheduleDayValue = $dayOrder[$schedule->hari] ?? 0; - - // Calculate days difference + $scheduleDayValue = self::DAY_ORDER[$schedule->hari] ?? 0; $dayDiff = ($scheduleDayValue - $currentDayValue + 7) % 7; - // If same day but time passed, add 7 days if ($dayDiff === 0 && $schedule->waktu <= $currentTime) { $dayDiff = 7; } - // Find schedule with smallest day difference if ($dayDiff < $minDayDiff) { $minDayDiff = $dayDiff; $closestSchedule = $schedule; } } - if ($closestSchedule) { - return $this->formatScheduleResponse($closestSchedule); - } - - return response()->json([ - 'success' => false, - 'message' => 'Tidak ada jadwal aktif yang akan datang' - ]); + return $closestSchedule + ? $this->formatScheduleResponse($closestSchedule) + : response()->json([ + 'success' => false, + 'message' => 'Tidak ada jadwal aktif yang akan datang' + ]); } - private function formatScheduleResponse($schedule) + private function formatScheduleResponse(JadwalBel $schedule) { return response()->json([ 'success' => true, @@ -609,7 +578,59 @@ private function formatScheduleResponse($schedule) ]); } - protected function validateSchedule(Request $request) + // public function history(Request $request) + // { + // try { + // $query = BellHistory::query()->latest('ring_time'); + + // if ($request->filled('date')) { + // $query->whereDate('ring_time', $request->date); + // } + + // if ($request->filled('search')) { + // $query->where(function($q) use ($request) { + // $q->where('hari', 'like', '%'.$request->search.'%') + // ->orWhere('file_number', 'like', '%'.$request->search.'%') + // ->orWhere('trigger_type', 'like', '%'.$request->search.'%'); + // }); + // } + + // return view('admin.bel.history', [ + // 'histories' => $query->paginate(15) + // ]); + // } catch (\Exception $e) { + // Log::error('Error fetching history: ' . $e->getMessage()); + // return back()->with('error', 'Gagal memuat riwayat bel'); + // } + // } + + public function logEvent(Request $request) + { + $validated = $request->validate([ + 'hari' => 'required|string', + 'waktu' => 'required|date_format:H:i:s', + 'file_number' => 'required|string|size:4', + 'trigger_type' => 'required|in:schedule,manual', + 'volume' => 'sometimes|integer|min:1|max:30', + 'repeat' => 'sometimes|integer|min:1|max:5' + ]); + + try { + return response()->json([ + 'success' => true, + 'message' => 'Bell event logged', + 'data' => BellHistory::create($validated) + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to log bell event', + 'error' => $e->getMessage() + ], 500); + } + } + + protected function validateSchedule(Request $request): array { return $request->validate([ 'hari' => 'required|in:' . implode(',', JadwalBel::DAYS), @@ -619,79 +640,8 @@ protected function validateSchedule(Request $request) ]); } - protected function logActivity($action, JadwalBel $schedule) + protected function logActivity(string $action, JadwalBel $schedule): void { Log::info("{$action} - ID: {$schedule->id}, Hari: {$schedule->hari}, Waktu: {$schedule->waktu}, File: {$schedule->file_number}"); } - - protected function logMqttActivity($action, $message) - { - $this->mqttService->publish( - $this->mqttConfig['topics']['system']['logs'], - json_encode([ - 'action' => $action, - 'message' => $message, - 'timestamp' => Carbon::now()->toDateTimeString() - ]), - 0 - ); - } - - protected function handleBellRing(string $message) - { - try { - $data = json_decode($message, true); - - // Validasi data yang diterima - if (!isset($data['hari']) || !isset($data['waktu']) || !isset($data['file_number']) || !isset($data['trigger_type'])) { - Log::error('Invalid bell ring data format'); - return; - } - - // Simpan ke history - BellHistory::create([ - 'hari' => $data['hari'], - 'waktu' => $data['waktu'], - 'file_number' => $data['file_number'], - 'trigger_type' => $data['trigger_type'], - 'ring_time' => Carbon::now(), - 'volume' => $data['volume'] ?? 15, // Default volume 15 - 'repeat' => $data['repeat'] ?? 1 // Default repeat 1x - ]); - - Log::info('Bell ring recorded to history', ['data' => $data]); - - } catch (\Exception $e) { - Log::error('Error handling bell ring: ' . $e->getMessage()); - } - } - - public function history(Request $request) - { - try { - $query = BellHistory::query()->latest('ring_time'); - - if ($request->filled('date')) { - $query->whereDate('ring_time', $request->date); - } - - if ($request->filled('search')) { - $query->where(function($q) use ($request) { - $q->where('hari', 'like', '%'.$request->search.'%') - ->orWhere('file_number', 'like', '%'.$request->search.'%') - ->orWhere('trigger_type', 'like', '%'.$request->search.'%'); - }); - } - - $histories = $query->paginate(15); - - return view('admin.bel.history', [ - 'histories' => $histories - ]); - - } catch (\Exception $e) { - Log::error('Error fetching history: ' . $e->getMessage()); - return back()->with('error', 'Gagal memuat riwayat bel'); - } - } } \ No newline at end of file diff --git a/app/Models/Announcement.php b/app/Models/Announcement.php index db4b09d..4c81bdd 100644 --- a/app/Models/Announcement.php +++ b/app/Models/Announcement.php @@ -9,7 +9,7 @@ class Announcement extends Model { use HasFactory; - protected $table = 'announcements'; + protected $table = 'announcements'; // Pastikan konsisten protected $fillable = [ 'mode', @@ -17,34 +17,41 @@ class Announcement extends Model 'audio_path', 'voice', 'speed', - 'ruangan', - 'user_id', - 'sent_at' + 'is_active', + 'status', + 'error_message', + 'sent_at', + 'relay_state' // Tambahkan ini + ]; + + protected $attributes = [ + 'is_active' => true, + 'status' => 'pending', + 'relay_state' => 'OFF' // Default value ]; protected $casts = [ - 'ruangan' => 'array', + 'is_active' => 'boolean', 'sent_at' => 'datetime' ]; - /** - * Relasi ke user yang membuat pengumuman - */ - public function user() + // Tambahkan aksesor untuk relay + public function getRelayStateDescriptionAttribute() { - return $this->belongsTo(User::class); + return $this->relay_state === 'ON' ? 'Relay Menyala' : 'Relay Mati'; } /** - * Relasi many-to-many ke ruangan + * Relationship with Ruangan (many-to-many) */ public function ruangans() { - return $this->belongsToMany(Ruangan::class, 'announcement_ruangan'); + return $this->belongsToMany(Ruangan::class, 'announcement_ruangan') + ->withTimestamps(); } /** - * Scope untuk pengumuman reguler + * Scope for regular announcements */ public function scopeReguler($query) { @@ -52,7 +59,7 @@ public function scopeReguler($query) } /** - * Scope untuk pengumuman TTS + * Scope for TTS announcements */ public function scopeTts($query) { @@ -60,21 +67,23 @@ public function scopeTts($query) } /** - * Scope untuk pencarian + * Scope for delivered announcements */ - public function scopeSearch($query, $search) + public function scopeDelivered($query) { - return $query->where('message', 'like', "%{$search}%") - ->orWhereHas('ruangans', function($q) use ($search) { - $q->where('nama_ruangan', 'like', "%{$search}%"); - }) - ->orWhereHas('user', function($q) use ($search) { - $q->where('name', 'like', "%{$search}%"); - }); + return $query->where('status', 'delivered'); } /** - * Accessor untuk audio URL + * Scope for failed announcements + */ + public function scopeFailed($query) + { + return $query->where('status', 'failed'); + } + + /** + * Accessor for audio URL */ public function getAudioUrlAttribute() { @@ -82,10 +91,34 @@ public function getAudioUrlAttribute() } /** - * Format tanggal pengiriman + * Accessor for formatted sent time */ public function getFormattedSentAtAttribute() { return $this->sent_at->format('d M Y H:i:s'); } + + /** + * Accessor untuk pesan aktivasi + */ + public function getActivationMessageAttribute() + { + return $this->is_active ? 'Aktivasi Ruangan' : 'Deaktivasi Ruangan'; + } + + /** + * Cek apakah pengumuman reguler + */ + public function isReguler() + { + return $this->mode === 'reguler'; + } + + /** + * Cek apakah pengumuman TTS + */ + public function isTts() + { + return $this->mode === 'tts'; + } } \ No newline at end of file diff --git a/app/Models/BellHistory.php b/app/Models/BellHistory.php index c4e48aa..8cc8269 100644 --- a/app/Models/BellHistory.php +++ b/app/Models/BellHistory.php @@ -4,35 +4,51 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; class BellHistory extends Model { use HasFactory; - const TRIGGER_SCHEDULE = 'schedule'; - const TRIGGER_MANUAL = 'manual'; - protected $fillable = [ 'hari', 'waktu', - 'file_number', + 'file_number', 'trigger_type', - 'ring_time', 'volume', - 'repeat' + 'repeat', + 'ring_time' ]; - + protected $casts = [ 'ring_time' => 'datetime', - 'volume' => 'integer', - 'repeat' => 'integer' + 'waktu' => 'datetime:H:i:s', // Format waktu konsisten ]; + - public static function getTriggerTypes() + // Validasi otomatis saat create/update + public static function rules() { return [ - self::TRIGGER_SCHEDULE => 'Jadwal', - self::TRIGGER_MANUAL => 'Manual' + 'hari' => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Minggu', + 'waktu' => 'required|date_format:H:i:s', + 'file_number' => 'required|string|size:4|regex:/^[0-9]{4}$/', + 'trigger_type' => 'required|in:schedule,manual', + 'volume' => 'sometimes|integer|min:0|max:30', + 'repeat' => 'sometimes|integer|min:1|max:5' ]; } + + // Normalisasi waktu sebelum simpan + public function setWaktuAttribute($value) + { + try { + $this->attributes['waktu'] = Carbon::createFromFormat('H:i:s', $value)->format('H:i:s'); + } catch (\Exception $e) { + // Fallback ke format default jika parsing gagal + $this->attributes['waktu'] = $value; + } + } + + } \ No newline at end of file diff --git a/app/Models/Ruangan.php b/app/Models/Ruangan.php index 14224e2..e33a8ac 100644 --- a/app/Models/Ruangan.php +++ b/app/Models/Ruangan.php @@ -14,11 +14,18 @@ class Ruangan extends Model protected $fillable = [ 'nama_ruangan', 'id_kelas', - 'id_jurusan' + 'id_jurusan', + 'relay_state', // Ubah dari status_relay menjadi relay_state + 'mqtt_topic' // Tambahkan kolom untuk custom MQTT topic ]; + protected $casts = [ + 'relay_state' => 'string' // Ubah menjadi string untuk menyimpan 'ON'/'OFF' + ]; + + /** - * Relasi ke model Kelas + * Relationship with Kelas */ public function kelas() { @@ -26,17 +33,15 @@ public function kelas() } /** - * Relasi ke model Jurusan + * Relationship with Jurusan */ public function jurusan() { return $this->belongsTo(Jurusan::class, 'id_jurusan'); } - // Di app/Models/Ruangan.php - /** - * Relasi many-to-many ke announcements + * Relationship with Announcements (many-to-many) */ public function announcements() { @@ -44,7 +49,7 @@ public function announcements() } /** - * Accessor untuk nama ruangan + * Accessor for uppercase room name */ public function getNamaRuanganAttribute($value) { @@ -52,24 +57,18 @@ public function getNamaRuanganAttribute($value) } /** - * Mutator untuk nama ruangan + * Scope for active relay status */ - public function setNamaRuanganAttribute($value) + public function scopeRelayActive($query) { - $this->attributes['nama_ruangan'] = strtolower($value); + return $query->where('status_relay', true); } /** - * Scope untuk pencarian ruangan + * Scope for inactive relay status */ - public function scopeSearch($query, $term) + public function scopeRelayInactive($query) { - return $query->where('nama_ruangan', 'like', "%{$term}%") - ->orWhereHas('kelas', function($q) use ($term) { - $q->where('nama_kelas', 'like', "%{$term}%"); - }) - ->orWhereHas('jurusan', function($q) use ($term) { - $q->where('nama_jurusan', 'like', "%{$term}%"); - }); + return $query->where('status_relay', false); } } \ No newline at end of file diff --git a/app/Services/MqttService.php b/app/Services/MqttService.php index c831909..8c0e5ae 100644 --- a/app/Services/MqttService.php +++ b/app/Services/MqttService.php @@ -6,41 +6,168 @@ use PhpMqtt\Client\ConnectionSettings; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Cache; +use App\Models\BellHistory; +use App\Events\BellRingEvent; +use Carbon\Carbon; +use Illuminate\Support\Facades\Validator; class MqttService { - protected $client; - protected $config; - protected $isConnected = false; - protected $reconnectAttempts = 0; - protected $lastActivityTime; - protected $connectionLock = false; + protected MqttClient $client; + protected array $config; + protected bool $isConnected = false; + protected int $reconnectAttempts = 0; + protected bool $connectionLock = false; + protected array $subscriptions = []; + protected const MAX_RECONNECT_ATTEMPTS = 5; + protected const RECONNECT_DELAY = 5; public function __construct() { - $this->config = config('mqtt.connections.bel_sekolah'); + $this->config = config('mqtt'); $this->initializeConnection(); } protected function initializeConnection(): void { try { + $connectionConfig = $this->getConnectionConfig(); $this->client = new MqttClient( - $this->config['host'], - $this->config['port'], - $this->config['client_id'] . '_' . uniqid() + $connectionConfig['host'], + $connectionConfig['port'], + $this->generateClientId($connectionConfig) ); $this->connect(); + $this->subscribeToBellTopics(); } catch (\Exception $e) { Log::error('MQTT Initialization failed: ' . $e->getMessage()); $this->scheduleReconnect(); } } + protected function getConnectionConfig(): array + { + return $this->config['connections'][$this->config['default_connection']]; + } + + protected function generateClientId(array $config): string + { + return $config['client_id'] . '_' . uniqid(); + } + + protected function subscribeToBellTopics(): void + { + $topics = [ + 'bell_schedule' => fn($t, $m) => $this->handleBellNotification($m, 'bell_schedule'), + 'bell_manual' => fn($t, $m) => $this->handleBellNotification($m, 'bell_manual') + ]; + + foreach ($topics as $type => $callback) { + $this->subscribe($this->config['topics']['events'][$type], $callback); + } + } + + protected function handleBellNotification(string $message, string $triggerType): void + { + Log::debug("Processing {$triggerType} bell event", compact('message', 'triggerType')); + + try { + $data = $this->validateBellData($message, $triggerType); + $history = $this->createBellHistory($data, $triggerType); + + $this->logBellEvent($history, $triggerType); + $this->dispatchBellEvent($history); + } catch (\JsonException $e) { + Log::error("Invalid JSON format in bell notification", [ + 'error' => $e->getMessage(), + 'message' => $message + ]); + } catch (\InvalidArgumentException $e) { + Log::error("Validation failed for bell event", [ + 'error' => $e->getMessage(), + 'data' => $data ?? null + ]); + } catch (\Exception $e) { + Log::error("Failed to process bell notification", [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'trigger_type' => $triggerType + ]); + } + } + + protected function validateBellData(string $message, string $triggerType): array + { + $data = json_decode($message, true, 512, JSON_THROW_ON_ERROR); + + $rules = [ + 'hari' => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Minggu', + 'waktu' => 'required|date_format:H:i:s', + 'file_number' => 'required|string|size:4|regex:/^[0-9]{4}$/', + 'volume' => 'sometimes|integer|min:0|max:30', + 'repeat' => 'sometimes|integer|min:1|max:5' + ]; + + $validator = Validator::make($data, $rules); + + if ($validator->fails()) { + throw new \InvalidArgumentException( + 'Invalid bell data: ' . $validator->errors()->first() + ); + } + + return $data; + } + + protected function createBellHistory(array $data, string $triggerType): BellHistory + { + return BellHistory::create([ + 'hari' => $data['hari'], + 'waktu' => $this->normalizeTime($data['waktu']), + 'file_number' => $data['file_number'], + 'trigger_type' => $triggerType === 'bell_schedule' ? 'schedule' : 'manual', + 'volume' => $data['volume'] ?? 15, + 'repeat' => $data['repeat'] ?? 1, + 'ring_time' => now() + ]); + } + + protected function logBellEvent(BellHistory $history, string $triggerType): void + { + Log::info("Bell event saved successfully", [ + 'id' => $history->id, + 'type' => $triggerType, + 'hari' => $history->hari, + 'waktu' => $history->waktu, + 'file' => $history->file_number + ]); + } + + protected function dispatchBellEvent(BellHistory $history): void + { + event(new BellRingEvent([ + 'id' => $history->id, + 'hari' => $history->hari, + 'waktu' => $history->waktu, + 'file_number' => $history->file_number, + 'trigger_type' => $history->trigger_type, + 'ring_time' => $history->ring_time->toDateTimeString() + ])); + } + + private function normalizeTime(string $time): string + { + try { + return Carbon::createFromFormat('H:i:s', $time)->format('H:i:s'); + } catch (\Exception $e) { + $parts = explode(':', $time); + return sprintf("%02d:%02d:%02d", $parts[0] ?? 0, $parts[1] ?? 0, $parts[2] ?? 0); + } + } + public function connect(): bool { - // Prevent multiple simultaneous connection attempts if ($this->connectionLock) { return false; } @@ -53,131 +180,114 @@ public function connect(): bool return true; } - $connectionSettings = (new ConnectionSettings) - ->setKeepAliveInterval($this->config['connection_settings']['keep_alive_interval']) - ->setConnectTimeout($this->config['connection_settings']['connect_timeout']) - ->setLastWillTopic($this->config['connection_settings']['last_will']['topic']) - ->setLastWillMessage($this->config['connection_settings']['last_will']['message']) - ->setLastWillQualityOfService($this->config['connection_settings']['last_will']['quality_of_service']) - ->setRetainLastWill($this->config['connection_settings']['last_will']['retain']) - ->setUseTls(false); - + $connectionSettings = $this->createConnectionSettings(); $this->client->connect($connectionSettings, true); - $this->isConnected = true; - $this->reconnectAttempts = 0; - $this->lastActivityTime = time(); - - // Store connection status in cache for UI - Cache::put('mqtt_status', 'connected', 60); - - Log::info('MQTT Connected successfully to ' . $this->config['host']); - $this->connectionLock = false; + + $this->handleSuccessfulConnection(); return true; } catch (\Exception $e) { - Log::error('MQTT Connection failed: ' . $e->getMessage()); - $this->handleDisconnection(); + $this->handleConnectionFailure($e); + return false; + } finally { $this->connectionLock = false; + } + } + + protected function createConnectionSettings(): ConnectionSettings + { + $connectionConfig = $this->getConnectionConfig()['connection_settings']; + $lastWill = $connectionConfig['last_will']; + + return (new ConnectionSettings) + ->setUsername($connectionConfig['username'] ?? null) + ->setPassword($connectionConfig['password'] ?? null) + ->setKeepAliveInterval($connectionConfig['keep_alive_interval']) + ->setConnectTimeout($connectionConfig['connect_timeout']) + ->setLastWillTopic($lastWill['topic']) + ->setLastWillMessage($lastWill['message']) + ->setLastWillQualityOfService($lastWill['quality_of_service']) + ->setRetainLastWill($lastWill['retain']) + ->setUseTls(false); + } + + protected function handleSuccessfulConnection(): void + { + $this->isConnected = true; + $this->reconnectAttempts = 0; + $this->resubscribeToTopics(); + + Cache::put('mqtt_status', 'connected', 60); + Log::info('MQTT Connected successfully'); + } + + protected function resubscribeToTopics(): void + { + foreach ($this->subscriptions as $topic => $callback) { + $this->client->subscribe($topic, $callback); + } + } + + protected function handleConnectionFailure(\Exception $e): void + { + Log::error('MQTT Connection failed: ' . $e->getMessage()); + $this->handleDisconnection(); + } + + public function subscribe(string $topic, callable $callback, int $qos = 0): bool + { + $this->subscriptions[$topic] = $callback; + + if (!$this->isConnected) { + return false; + } + + try { + $this->client->subscribe($topic, $callback, $qos); + Log::debug("MQTT Subscribed to {$topic}"); + return true; + } catch (\Exception $e) { + Log::error("MQTT Subscribe failed to {$topic}: " . $e->getMessage()); return false; } } - protected function checkConnection(): void + public function publish(string $topic, string $message, int $qos = 0, bool $retain = false): bool { - try { - // Simple ping test with short timeout - $this->client->publish($this->config['connection_settings']['last_will']['topic'], 'ping', 0, false); - $this->lastActivityTime = time(); - } catch (\Exception $e) { - $this->handleDisconnection(); - } - } - - public function ensureConnected(): bool - { - if (!$this->isConnected) { - return $this->connect(); - } - - // Check if connection is stale - if ((time() - $this->lastActivityTime) > $this->config['connection_settings']['keep_alive_interval']) { - $this->checkConnection(); - } - - return $this->isConnected; - } - - protected function handleDisconnection(): void - { - $this->isConnected = false; - Cache::put('mqtt_status', 'disconnected', 60); - Log::warning('MQTT Disconnection detected'); - $this->scheduleReconnect(); - } - - protected function scheduleReconnect(): void - { - $maxAttempts = $this->config['connection_settings']['auto_reconnect']['max_reconnect_attempts'] ?? 5; - $delay = $this->config['connection_settings']['auto_reconnect']['delay_between_reconnect_attempts'] ?? 2; - - if ($this->reconnectAttempts < $maxAttempts) { - $this->reconnectAttempts++; - $actualDelay = $delay * $this->reconnectAttempts; - - Log::info("Attempting MQTT reconnect ({$this->reconnectAttempts}/{$maxAttempts}) in {$actualDelay} seconds"); - - sleep($actualDelay); - $this->connect(); - } else { - Log::error('MQTT Max reconnect attempts reached'); - Cache::put('mqtt_status', 'disconnected', 60); - } - } - - public function isConnected(): bool - { - return $this->isConnected; - } - - public function publish($topic, $message, $qos = 0, $retain = false): bool - { - if (!$this->ensureConnected()) { - // Store message in queue if disconnected - $this->storeMessageInQueue($topic, $message, $qos, $retain); + if (!$this->isConnected && !$this->connect()) { + $this->queueMessage($topic, $message, $qos, $retain); return false; } try { $this->client->publish($topic, $message, $qos, $retain); - $this->lastActivityTime = time(); - Log::debug("MQTT Published to {$topic}: {$message}"); + Log::debug("MQTT Published to {$topic}"); return true; } catch (\Exception $e) { - $this->handleDisconnection(); - Log::error("MQTT Publish failed to {$topic}: " . $e->getMessage()); - - // Store message in queue if failed - $this->storeMessageInQueue($topic, $message, $qos, $retain); + $this->handlePublishFailure($e, $topic, $message, $qos, $retain); return false; } } - protected function storeMessageInQueue($topic, $message, $qos, $retain): void + protected function handlePublishFailure(\Exception $e, string $topic, string $message, int $qos, bool $retain): void + { + Log::error("MQTT Publish failed to {$topic}: " . $e->getMessage()); + $this->handleDisconnection(); + $this->queueMessage($topic, $message, $qos, $retain); + } + + protected function queueMessage(string $topic, string $message, int $qos, bool $retain): void { $queue = Cache::get('mqtt_message_queue', []); - $queue[] = [ - 'topic' => $topic, - 'message' => $message, - 'qos' => $qos, - 'retain' => $retain, + $queue[] = compact('topic', 'message', 'qos', 'retain') + [ 'attempts' => 0, - 'timestamp' => time(), + 'timestamp' => now()->toDateTimeString(), ]; Cache::put('mqtt_message_queue', $queue, 3600); } public function processMessageQueue(): void { - if (!$this->isConnected()) { + if (!$this->isConnected) { return; } @@ -186,17 +296,16 @@ public function processMessageQueue(): void foreach ($queue as $message) { if ($message['attempts'] >= 3) { - continue; // Skip messages that failed too many times + continue; } try { - $this->client->publish( + $this->publish( $message['topic'], $message['message'], $message['qos'], $message['retain'] ); - $this->lastActivityTime = time(); } catch (\Exception $e) { $message['attempts']++; $remainingMessages[] = $message; @@ -206,105 +315,42 @@ public function processMessageQueue(): void Cache::put('mqtt_message_queue', $remainingMessages, 3600); } - public function subscribe($topic, $callback, $qos = 0): bool + public function isConnected(): bool { - if (!$this->ensureConnected()) { - return false; - } - - try { - $this->client->subscribe($topic, $callback, $qos); - $this->lastActivityTime = time(); - Log::info("MQTT Subscribed to {$topic}"); - return true; - } catch (\Exception $e) { - $this->handleDisconnection(); - Log::error("MQTT Subscribe failed to {$topic}: " . $e->getMessage()); - return false; - } + return $this->isConnected; } - public function loop(bool $allowSleep = true): void + protected function handleDisconnection(): void { - if ($this->isConnected()) { - try { - $this->client->loop($allowSleep); - $this->lastActivityTime = time(); - $this->processMessageQueue(); - } catch (\Exception $e) { - $this->handleDisconnection(); - } - } else { + $this->isConnected = false; + Cache::put('mqtt_status', 'disconnected', 60); + $this->scheduleReconnect(); + } + + protected function scheduleReconnect(): void + { + $reconnectConfig = $this->getConnectionConfig()['connection_settings']['auto_reconnect']; + + if (!$reconnectConfig['enabled']) { + return; + } + + if ($this->reconnectAttempts < $reconnectConfig['max_reconnect_attempts']) { + $this->reconnectAttempts++; + $delay = $reconnectConfig['delay_between_reconnect_attempts'] * $this->reconnectAttempts; + + Log::info("MQTT Reconnect attempt {$this->reconnectAttempts} in {$delay} seconds"); + sleep($delay); $this->connect(); + } else { + Log::error('MQTT Max reconnect attempts reached'); } } - public function disconnect(): void - { - if ($this->isConnected) { - try { - $this->client->disconnect(); - $this->isConnected = false; - Cache::put('mqtt_status', 'disconnected', 60); - Log::info('MQTT Disconnected gracefully'); - } catch (\Exception $e) { - Log::error('MQTT Disconnection error: ' . $e->getMessage()); - } - } - } - - public static function quickPublish($topic, $message, $qos = 0, $retain = false): bool - { - try { - $config = config('mqtt.connections.bel_sekolah'); - - $mqtt = new MqttClient( - $config['host'], - $config['port'], - 'quick-publish-' . uniqid() - ); - - $connectionSettings = (new ConnectionSettings) - ->setConnectTimeout($config['connection_settings']['connect_timeout'] ?? 2) - ->setUseTls(false); - - $mqtt->connect($connectionSettings, true); - - $mqtt->publish($topic, $message, $qos, $retain); - $mqtt->disconnect(); - return true; - } catch (\Throwable $e) { - Log::error('Quick MQTT publish failed: ' . $e->getMessage()); - return false; - } - } - - public function __destruct() { - // Disconnect only if explicitly needed if ($this->isConnected) { - $this->disconnect(); + $this->client->disconnect(); } } - - public function sendAnnouncement($payload) - { - $topic = 'bel/sekolah/pengumuman'; - - // Publish utama - $this->publish($topic, json_encode($payload), 1, false); - - // Jika TTS, kirim perintah stop setelah delay - if ($payload['type'] === 'tts' && $payload['auto_stop'] ?? false) { - - $stopPayload = [ - 'type' => 'stop_tts', - 'target_ruangans' => $payload['target_ruangans'], - 'timestamp' => now()->toDateTimeString() - ]; - $this->publish($topic, json_encode($stopPayload), 1, false); - } - - } } \ No newline at end of file diff --git a/config/mqtt.php b/config/mqtt.php index c5c12e6..b5e0237 100644 --- a/config/mqtt.php +++ b/config/mqtt.php @@ -5,7 +5,7 @@ use PhpMqtt\Client\MqttClient; return [ - 'default_connection' => 'sekolah', + 'default_connection' => 'bel_sekolah', 'connections' => [ 'bel_sekolah' => [ @@ -14,6 +14,11 @@ 'client_id' => env('MQTT_CLIENT_ID', 'laravel_bel_' . bin2hex(random_bytes(4))), 'use_clean_session' => false, 'connection_settings' => [ + 'auto_reconnect' => [ + 'enabled' => true, + 'max_reconnect_attempts' => 5, + 'delay_between_reconnect_attempts' => 3, + ], 'last_will' => [ 'topic' => 'bel/sekolah/status/backend', 'message' => json_encode(['status' => 'offline']), @@ -30,19 +35,30 @@ // Topic configuration 'topics' => [ 'commands' => [ + 'announcement' => 'announcement/command', 'ring' => 'bel/sekolah/command/ring', 'sync' => 'bel/sekolah/command/sync', 'status' => 'bel/sekolah/command/status', + 'relay_control' => 'ruangan/+/relay/control', ], 'responses' => [ + 'announcement_ack' => 'announcement/response/ack', + 'announcement_error' => 'announcement/response/error', 'status' => 'bel/sekolah/response/status', 'ack' => 'bel/sekolah/response/ack', - 'bell_ring' => 'bel/sekolah/response/ring', + 'relay_status' => 'ruangan/+/relay/status', ], - 'announcements' => [ - 'general' => 'bel/sekolah/pengumuman', - 'emergency' => 'bel/sekolah/emergency', - 'feedback' => 'bel/sekolah/status/pengumuman', + 'events' => [ // [!++ Add this section ++!] + 'bell_schedule' => 'bel/sekolah/events/schedule', + 'bell_manual' => 'bel/sekolah/events/manual' ], ], + + // QoS Levels + 'qos_levels' => [ + 'default' => 0, + 'announcement' => 1, + 'relay_control' => 1, // QoS 1 untuk kontrol relay + 'status_updates' => 1, + ], ]; \ No newline at end of file diff --git a/config/services.php b/config/services.php index 47cb37f..d8019c0 100644 --- a/config/services.php +++ b/config/services.php @@ -34,10 +34,4 @@ 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), ], ], - - 'voicerss' => [ - 'api_key' => env('VOICERSS_API_KEY'), - ], - - ]; diff --git a/database/migrations/2025_04_21_051528_create_ruangan_table.php b/database/migrations/2025_04_21_051528_create_ruangan_table.php index d597e98..8370979 100644 --- a/database/migrations/2025_04_21_051528_create_ruangan_table.php +++ b/database/migrations/2025_04_21_051528_create_ruangan_table.php @@ -11,16 +11,15 @@ public function up(): void Schema::create('ruangan', function (Blueprint $table) { $table->id(); $table->string('nama_ruangan'); - - // Foreign keys $table->foreignId('id_kelas')->constrained('kelas')->onDelete('cascade'); $table->foreignId('id_jurusan')->constrained('jurusan')->onDelete('cascade'); - + $table->string('relay_state')->default('OFF'); // Ubah tipe data + $table->string('mqtt_topic')->nullable(); // Untuk custom topic per ruangan $table->timestamps(); - // Tambahkan index untuk pencarian $table->index('nama_ruangan'); $table->index(['id_kelas', 'id_jurusan']); + $table->index('relay_state'); }); } diff --git a/database/migrations/2025_04_22_135609_create_announcements_table.php b/database/migrations/2025_04_22_135609_create_announcements_table.php index 0e248fa..dc19d8a 100644 --- a/database/migrations/2025_04_22_135609_create_announcements_table.php +++ b/database/migrations/2025_04_22_135609_create_announcements_table.php @@ -10,18 +10,21 @@ public function up() { Schema::create('announcements', function (Blueprint $table) { $table->id(); - $table->string('mode'); // 'reguler' atau 'tts' + $table->string('mode'); $table->text('message'); $table->string('audio_path')->nullable(); $table->string('voice')->nullable(); $table->integer('speed')->nullable(); - $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->boolean('is_active')->default(true); + $table->string('status')->default('pending'); + $table->text('error_message')->nullable(); + $table->string('relay_state')->default('OFF'); // Tambahkan kolom ini $table->timestamp('sent_at')->useCurrent(); $table->timestamps(); $table->index('mode'); $table->index('sent_at'); - $table->index('user_id'); + $table->index('relay_state'); // Tambahkan index }); // Perbaikan utama: Explicitly specify table names @@ -29,6 +32,7 @@ public function up() $table->id(); $table->foreignId('announcement_id')->constrained('announcements')->onDelete('cascade'); $table->foreignId('ruangan_id')->constrained('ruangan')->onDelete('cascade'); + $table->string('relay_state_at_time')->nullable(); // State saat pengumuman dikirim $table->timestamps(); $table->unique(['announcement_id', 'ruangan_id']); diff --git a/database/migrations/2025_04_24_203925_create_bell_history_table.php b/database/migrations/2025_04_24_203925_create_bell_history_table.php index 50d9465..453645e 100644 --- a/database/migrations/2025_04_24_203925_create_bell_history_table.php +++ b/database/migrations/2025_04_24_203925_create_bell_history_table.php @@ -10,14 +10,54 @@ public function up() { Schema::create('bell_histories', function (Blueprint $table) { $table->id(); - $table->string('hari'); + + // Hari dalam format Indonesia + $table->enum('hari', [ + 'Senin', + 'Selasa', + 'Rabu', + 'Kamis', + 'Jumat', + 'Sabtu', + 'Minggu' + ]); + + // Waktu dalam format HH:MM:SS $table->time('waktu'); - $table->string('file_number', 4); - $table->enum('trigger_type', ['schedule', 'manual']); - $table->integer('volume'); - $table->integer('repeat'); - $table->timestamp('ring_time'); + + // Nomor file 4 digit (0001-9999) + $table->char('file_number', 4); + + // Tipe trigger + $table->enum('trigger_type', ['schedule', 'manual']) + ->default('schedule'); + + // Volume 0-30 + $table->unsignedTinyInteger('volume') + ->default(15); + + // Repeat 1-5 kali + $table->unsignedTinyInteger('repeat') + ->default(1); + + // Waktu bel berbunyi + $table->timestamp('ring_time') + ->useCurrent(); + $table->timestamps(); + + // Optimasi query + $table->index('ring_time'); + $table->index(['hari', 'waktu']); + $table->index('file_number'); + + // Constraint untuk memastikan data unik + $table->unique([ + 'hari', + 'waktu', + 'file_number', + 'ring_time' + ], 'bell_event_unique'); }); } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 461def2..8977709 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,7 +17,8 @@ public function run(): void UserTableSeeder::class, JurusanSeeder::class, KelasSeeder::class, - SiswaTableSeeder::class + SiswaTableSeeder::class, + StatusSeeder::class ]); } } diff --git a/database/seeders/RuanganSeeder.php b/database/seeders/RuanganSeeder.php deleted file mode 100644 index d3847d9..0000000 --- a/database/seeders/RuanganSeeder.php +++ /dev/null @@ -1,48 +0,0 @@ -toArray(); - $jurusanIds = Jurusan::pluck('id')->toArray(); - - $data = []; - foreach ($kelasIds as $kelasId) { - foreach ($jurusanIds as $jurusanId) { - // Ekstrak angka dari nama kelas - $kelasNumber = (int) preg_replace('/[^0-9]/', '', Kelas::find($kelasId)->nama_kelas); - $jurusanNumber = array_search($jurusanId, $jurusanIds) + 1; - $nama_ruangan = "{$kelasNumber}.{$jurusanNumber}"; - - // Pastikan kombinasi kelas_id dan jurusan_id unik - if (!Ruangan::where('kelas_id', $kelasId) - ->where('jurusan_id', $jurusanId) - ->exists()) { - $data[] = [ - 'nama_ruangan' => $nama_ruangan, - 'kelas_id' => $kelasId, - 'jurusan_id' => $jurusanId, - ]; - } - } - } - - // Masukkan data ke tabel ruangan - foreach ($data as $item) { - Ruangan::create($item); - } - } -} \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index bd6213e..94a8395 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,3 +1,66 @@ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +.btn-blue { + @apply bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow-md hover:shadow-lg text-sm; +} +.btn-purple { + @apply bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow-md hover:shadow-lg text-sm; +} +.btn-yellow { + @apply bg-yellow-500 hover:bg-yellow-600 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow hover:shadow-md text-sm; +} +.btn-green { + @apply bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow hover:shadow-md text-sm; +} +.btn-gray { + @apply bg-gray-100 hover:bg-gray-200 text-gray-800 px-4 py-2 rounded-lg inline-flex items-center transition duration-300 border border-gray-300 text-sm; +} +.dropdown-item { + @apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left flex items-center; +} +.select-input { + @apply block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-2 text-sm; +} +.search-input { + @apply block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 pl-10 py-2 text-sm; +} + + + +.btn-primary { + @apply px-4 py-2 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl hover:from-blue-600 hover:to-indigo-700 transition duration-300 shadow-md hover:shadow-lg flex items-center; +} + +.btn-secondary { + @apply px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-xl text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all duration-300 flex items-center shadow-sm hover:shadow-md; +} + +.btn-reset { + @apply px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 transition duration-300 flex items-center; +} + +.form-input { + @apply block w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-700 transition duration-300 bg-white dark:bg-gray-700 text-gray-900 dark:text-white; +} + +.form-select { + @apply block w-full pl-3 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-xl shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-700 transition duration-300 bg-white dark:bg-gray-700 text-gray-900 dark:text-white; +} + +.badge-blue { + @apply px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-100; +} + +.badge-purple { + @apply px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-gradient-to-r from-purple-100 to-purple-200 dark:from-purple-900 dark:to-purple-800 text-purple-800 dark:text-purple-200; +} + +.badge-green { + @apply px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-gradient-to-r from-green-100 to-green-200 dark:from-green-900 dark:to-green-800 text-green-800 dark:text-green-200; +} + +.badge-gray { + @apply px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-full; +} \ No newline at end of file diff --git a/resources/views/admin/announcement/history.blade.php b/resources/views/admin/announcement/history.blade.php index 745cc23..0a537e0 100644 --- a/resources/views/admin/announcement/history.blade.php +++ b/resources/views/admin/announcement/history.blade.php @@ -1,117 +1,269 @@ @extends('layouts.dashboard') +@section('title', 'Riwayat Pengumuman') + @section('content') -
-
-
-

Riwayat Pengumuman

+
+ +
+
+

Riwayat Pengumuman

+

Daftar seluruh pengumuman yang pernah dikirim

+
+
-
-
-
-
-
-
- -
-
- -
-
-
- + + +
+ +
+ +
+ +
- +
+ + +
-
+
-
-
-
-
-
- - - - - - - - - - - - - - @forelse($announcements as $announcement) - - - - - - - - - - @empty - - - - @endforelse - -
#WaktuModeRuangan TujuanIsi PengumumanPengirimAksi
{{ $loop->iteration }}{{ $announcement->sent_at->format('d M Y H:i') }} - - {{ strtoupper($announcement->mode) }} - - - @foreach($announcement->ruangans as $ruangan) - {{ $ruangan->nama_ruangan }} - @endforeach - - @if($announcement->mode === 'tts') - - TTS: {{ Str::limit($announcement->message, 50) }} - @if($announcement->audio_path) - - - - @endif - @else - {{ Str::limit($announcement->message, 50) }} - @endif - {{ $announcement->user->name }} - - - -
- @csrf - @method('DELETE') - -
-
Belum ada pengumuman
-
-
- + + + + + + @endsection \ No newline at end of file diff --git a/resources/views/admin/announcement/index.blade.php b/resources/views/admin/announcement/index.blade.php index 27feeaf..41f72d3 100644 --- a/resources/views/admin/announcement/index.blade.php +++ b/resources/views/admin/announcement/index.blade.php @@ -1,477 +1,452 @@ @extends('layouts.dashboard') -@section('title', 'Manajemen Pengumuman') +@section('title', 'Sistem Pengumuman Digital') @section('content') -
- -
-
-

Sistem Pengumuman Digital

-

Kelola dan kirim pengumuman ke ruangan terpilih

-
-
- - Riwayat - - -
-
- - -
- -
-

Formulir Pengumuman Baru

-
- - -
-
- @csrf - - -
- -
- - -
+
+ +
+
+
+
+

Sistem Pengumuman Digital

+

Kontrol Terpusat untuk Ruangan dan Pengumuman

- - -
-
- -
- -
- 0/500 -
-
+
+
+
+ MQTT: {{ $mqttStatus }}
-
- - - - - -
-
- -
- - -
-
- -
-
- @foreach($ruangan as $room) - - @endforeach -
-
-
- - -
-
- +
- -
- -
-
-

Pengumuman Terakhir

-
- Menampilkan {{ $announcements->count() }} dari {{ $announcements->total() }} pengumuman -
+ +
+ +
+
+
-
- - -
- @if($announcements->isEmpty()) -
-
- -
-

Belum Ada Pengumuman

-

Mulailah dengan membuat pengumuman baru

-
- @else -
- @foreach($announcements as $announcement) -
-
-
-
- @if($announcement->mode === 'reguler') -
- + +
+ +
+
+ @csrf + + +
+ +
+
+

Pilih Ruangan

+
- @else -
- + +
+
+ @foreach($ruangans as $ruangan) +
+ + +
+ @endforeach +
+ @error('ruangans') +

{{ $message }}

+ @enderror
- @endif
+ +
-

- {{ $announcement->message }} -

-
-
- - {{ $announcement->user->name }} +

Kontrol Relay

+
+
+
+ + +
+
+ + +
-
- - {{ $announcement->sent_at->diffForHumans() }} -
-
- - {{ count($announcement->ruangan) }} ruangan + +
+
- +
+ + +
+
+

Riwayat Pengumuman Terakhir

+
+
+ + + + + + + + + + + @forelse($announcements as $announcement) + + + + + + + @empty + + + + @endforelse + +
WaktuModeKontenStatus
+
{{ $announcement->sent_at->format('d M Y') }}
+
{{ $announcement->sent_at->format('H:i') }}
+
+ + {{ $announcement->mode === 'reguler' ? 'Kontrol Relay' : 'Pengumuman Suara' }} + + +
+ @if($announcement->mode === 'reguler') + {{ $announcement->message }} + + (Relay: {{ $announcement->relay_state }}) + + @else + {{ Str::limit($announcement->message, 50) }} + @endif +
+
+ Ruangan: {{ $announcement->ruangans->pluck('nama_ruangan')->implode(', ') }} +
+
+
+ + {{ ucfirst($announcement->status) }} + + @if($announcement->error_message) + + @endif +
+
+ Belum ada riwayat pengumuman +
+
+
+ {{ $announcements->links() }} +
+
+
+
+ + +