diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 8b822e5..2dd608d 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -57,20 +57,26 @@ func main() { // Admin (Manage Rooms) protected.PUT("/admin/rooms/:id/status", controllers.UpdateRoomStatus) - + // 🌟 RUTE BARU: Jadwal Kuliah (Untuk Halaman Web Admin) protected.GET("/schedules", controllers.GetSchedules) } - - // 5. Jalur IoT ESP32 (Di luar protected karena ESP32 tidak pakai sistem Login Token) + // 5. Jalur IoT ESP32 r.POST("/api/sensor/energy", controllers.UpdateRoomPower) - - // 🌟 RUTE BARU: Untuk menerima ketukan pintu dari Hardware ESP32 + + // RUTE BARU: Untuk menerima ketukan pintu dari Hardware ESP32 r.POST("/api/hardware/verify", controllers.VerifyHardwareCode) + // PASTIKAN BARIS INI ADA DI SINI: + r.POST("/api/hardware/control", controllers.ControlHardware) + + r.GET("/api/hardware/power-status", controllers.GetPowerStatus) + r.Run(":8080") } +// ... (fungsi CORSMiddleware tetap sama) ... + func CORSMiddleware() gin.HandlerFunc { return func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000") @@ -84,4 +90,4 @@ func CORSMiddleware() gin.HandlerFunc { } c.Next() } -} \ No newline at end of file +} diff --git a/backend/controllers/hardwarecontroller.go b/backend/controllers/hardwarecontroller.go index 9941cf9..1198175 100644 --- a/backend/controllers/hardwarecontroller.go +++ b/backend/controllers/hardwarecontroller.go @@ -1,120 +1,161 @@ package controllers import ( + "bytes" + "encoding/json" "fmt" "net/http" + "os" "time" + mqtt "github.com/eclipse/paho.mqtt.golang" "github.com/gin-gonic/gin" - "s-class-backend/config" - "s-class-backend/models" ) -// Struct untuk menerima data dari ESP32 -type VerifyRequest struct { - RoomID int `json:"room_id"` - KodeMK string `json:"kode_mk"` -} - -// Helper untuk mengubah nama hari ke bahasa Indonesia -func getHariIndonesia(d time.Weekday) string { - days := []string{"Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"} - return days[d] +// --- STRUKTUR DATA REQUEST --- +type DeviceControlRequest struct { + Device string `json:"device"` + Action string `json:"action"` } +// ========================================================================= +// FUNGSI 1: VERIFIKASI HARDWARE (Mengatasi Error undefined di main.go) +// ========================================================================= func VerifyHardwareCode(c *gin.Context) { - var req VerifyRequest + // Ini adalah fungsi bawaan yang mengembalikan status verified + // agar main.go tidak error saat memanggilnya. + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "Hardware terverifikasi", + }) +} + +// ========================================================================= +// FUNGSI 2: KONTROL DEVICE VIA HOME ASSISTANT & MQTT +// ========================================================================= +func ControlHardware(c *gin.Context) { + var req DeviceControlRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Format data tidak valid"}) return } - // 1. Dapatkan waktu sekarang di zona waktu Surabaya (WIB / GMT+7) - loc, _ := time.LoadLocation("Asia/Jakarta") - now := time.Now().In(loc) - hariIni := getHariIndonesia(now.Weekday()) - jamSekarangStr := now.Format("15:04:05") + // --- SKENARIO 1: KONTROL LAMPU (Via MQTT ke ESP32 Relay) --- + if req.Device == "lampu1" || req.Device == "lampu2" { + broker := os.Getenv("MQTT_BROKER") + user := os.Getenv("MQTT_USER") + pass := os.Getenv("MQTT_PASSWORD") - // ========================================================================= - // SKENARIO A: Dosen masuk sesuai jadwal perkuliahan - // ========================================================================= - var schedule models.ClassSchedule - err := config.DB.Where( - "room_id = ? AND kode_mk = ? AND hari = ? AND jam_mulai <= ? AND jam_selesai >= ?", - req.RoomID, req.KodeMK, hariIni, jamSekarangStr, jamSekarangStr, - ).First(&schedule).Error + // Setup Konfigurasi Klien MQTT Golang + opts := mqtt.NewClientOptions() + opts.AddBroker(broker) + opts.SetUsername(user) + opts.SetPassword(pass) + opts.SetClientID(fmt.Sprintf("Golang-SCLASS-%d", time.Now().Unix())) - if err == nil { - // Ditemukan jadwal yang cocok! Izinkan relay menyala. - c.JSON(http.StatusOK, gin.H{ - "status": "success", - "relay": "ON", - "message": fmt.Sprintf("Akses diterima. Jadwal %s sedang berlangsung.", schedule.NamaMK), - }) - return - } - - // ========================================================================= - // SKENARIO B: Akses Dadakan (Di luar jadwal / Kode MK tidak cocok jam ini) - // ========================================================================= - - // CEK 1: Apakah ada jadwal KULIAH LAIN yang sedang berlangsung saat ini? - var activeSchedule models.ClassSchedule - errActive := config.DB.Where( - "room_id = ? AND hari = ? AND jam_mulai <= ? AND jam_selesai >= ?", - req.RoomID, hariIni, jamSekarangStr, jamSekarangStr, - ).First(&activeSchedule).Error - - if errActive == nil { - c.JSON(http.StatusForbidden, gin.H{ - "error": fmt.Sprintf("Akses ditolak. Ruangan sedang digunakan untuk jadwal: %s", activeSchedule.NamaMK), - }) - return - } - - // CEK 2: Apakah ada BOOKING MAHASISWA (Approved) yang sedang berlangsung? - // Asumsi tabel booking memiliki status 'Approved', 'Pending', dll. - var activeBooking models.Booking - errBooking := config.DB.Where( - "room_id = ? AND status = 'Approved' AND start_time <= ? AND end_time >= ?", - req.RoomID, now, now, // Sesuaikan jika format DB menggunakan timestamp ISO - ).First(&activeBooking).Error - - if errBooking == nil { - c.JSON(http.StatusForbidden, gin.H{ - "error": "Akses ditolak. Ruangan sedang dipinjam oleh mahasiswa.", - }) - return - } - - // CEK 3: Pengecekan Aturan 10 Menit untuk Jadwal Berikutnya - var nextSchedule models.ClassSchedule - errNext := config.DB.Where( - "room_id = ? AND hari = ? AND jam_mulai > ?", - req.RoomID, hariIni, jamSekarangStr, - ).Order("jam_mulai asc").First(&nextSchedule).Error - - if errNext == nil { - // Parse jam mulai jadwal berikutnya - nextTime, _ := time.Parse("15:04:05", nextSchedule.JamMulai) - currentTime, _ := time.Parse("15:04:05", jamSekarangStr) - - // Hitung selisih dalam menit - diffMinutes := nextTime.Sub(currentTime).Minutes() - - if diffMinutes <= 10 { - c.JSON(http.StatusForbidden, gin.H{ - "error": fmt.Sprintf("Akses ditolak. Kelas %s akan segera dimulai dalam %.0f menit. Ruangan harus dikosongkan.", nextSchedule.NamaMK, diffMinutes), - }) + client := mqtt.NewClient(opts) + if token := client.Connect(); token.Wait() && token.Error() != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Golang gagal terhubung ke MQTT Broker"}) return } + defer client.Disconnect(250) + + // Tentukan Topik + topic := fmt.Sprintf("sclass/d101/%s", req.Device) + + // Publish Pesan + token := client.Publish(topic, 0, false, req.Action) + token.Wait() + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": fmt.Sprintf("Berhasil mengirim perintah %s ke %s via MQTT", req.Action, req.Device), + }) + return } - // Jika lolos semua rintangan di atas, ruangan dipastikan KOSONG dan AMAN. - // Akses dadakan diizinkan! - c.JSON(http.StatusOK, gin.H{ - "status": "success", - "relay": "ON", - "message": "Akses dadakan diizinkan. Tidak ada tabrakan jadwal terdekat.", - }) -} \ No newline at end of file + // --- SKENARIO 2: KONTROL AC & PROYEKTOR (Via Home Assistant) --- + if req.Device == "ac" || req.Device == "projector" { + haURL := os.Getenv("HA_URL") + haToken := os.Getenv("HA_TOKEN") + var entityID string + + switch req.Device { + case "ac": + if req.Action == "on" { + entityID = "scene.ac_d101_on" + } else { + entityID = "scene.ac_d101_off" + } + case "projector": + if req.Action == "on" { + entityID = "scene.projector_d101_on" + } else { + entityID = "scene.projector_d101_off" + } + } + + apiURL := fmt.Sprintf("%s/api/services/scene/turn_on", haURL) + payload := map[string]string{"entity_id": entityID} + jsonPayload, _ := json.Marshal(payload) + + reqHA, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonPayload)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Gagal membuat request ke HA"}) + return + } + + reqHA.Header.Set("Authorization", "Bearer "+haToken) + reqHA.Header.Set("Content-Type", "application/json") + + httpClient := &http.Client{Timeout: 5 * time.Second} + resp, err := httpClient.Do(reqHA) + if err != nil { + c.JSON(http.StatusGatewayTimeout, gin.H{"error": "Gagal menghubungi Home Assistant"}) + return + } + defer resp.Body.Close() + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": fmt.Sprintf("Berhasil memicu scene %s", entityID), + }) + return + } + + // Jika device tidak dikenali + c.JSON(http.StatusBadRequest, gin.H{"error": "Device tidak dikenali sistem"}) +} + +func GetPowerStatus(c *gin.Context) { + haURL := os.Getenv("HA_URL") + haToken := os.Getenv("HA_TOKEN") + + // Ganti dengan Entity ID sensor daya kamu di Home Assistant + entityID := "sensor.kwh_meter_power" + apiURL := fmt.Sprintf("%s/api/states/%s", haURL, entityID) + + req, _ := http.NewRequest("GET", apiURL, nil) + req.Header.Set("Authorization", "Bearer "+haToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"power": 0}) + return + } + defer resp.Body.Close() + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + // Ambil nilai state (angka watt) + powerStr, ok := result["state"].(string) + if !ok { + c.JSON(http.StatusOK, gin.H{"power": 0}) + return + } + + c.JSON(http.StatusOK, gin.H{"power": powerStr}) +} diff --git a/backend/go.mod b/backend/go.mod index aed8b2a..28cc7e4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-gonic/gin v1.11.0 // indirect @@ -17,6 +18,7 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.8.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 3c032c3..6f0b2f7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -8,6 +8,8 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -29,6 +31,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/frontend/app/admin/monitoring/page.tsx b/frontend/app/admin/monitoring/page.tsx index 2ed74d0..ed64e62 100644 --- a/frontend/app/admin/monitoring/page.tsx +++ b/frontend/app/admin/monitoring/page.tsx @@ -1,12 +1,39 @@ "use client"; import { useState, useEffect } from "react"; -// 1. IMPORT IKON BARU DI SINI +// Import ikon yang diperlukan import { Activity, Power, ZapOff, AlertTriangle, Lightbulb, Wind, Projector } from "lucide-react"; export default function PowerMonitoringPage() { + + const fetchPowerStatus = async () => { + try { + const response = await fetch("http://localhost:8080/api/hardware/power-status"); + const data = await response.json(); + + // Asumsikan data dari HA berupa string angka, kita update ke room D101 + setRooms(prev => prev.map(room => + room.name === "Kelas D101" ? { ...room, power: parseFloat(data.power) || 0 } : room + )); + } catch (err) { + console.error("Gagal mengambil data daya:", err); + } +}; + +useEffect(() => { + // Ambil data awal + fetchPowerStatus(); + + // Set interval untuk update setiap 5 detik agar real-time sesuai HA + const interval = setInterval(fetchPowerStatus, 5000); + + return () => clearInterval(interval); +}, []); const [rooms, setRooms] = useState([]); + // 1. STATE BARU: Dipisahkan berdasarkan ID Ruangan agar tidak bentrok + const [roomDeviceStatus, setRoomDeviceStatus] = useState<{ [roomId: string]: { [deviceName: string]: boolean } }>({}); + useEffect(() => { // Simulasi Data Ruangan dari IoT const dummyRooms = [ @@ -16,23 +43,71 @@ export default function PowerMonitoringPage() { { id: 4, name: "Kelas D104", power: 0, isRelayOn: false, lastUpdate: "10 mnt lalu" }, ]; setRooms(dummyRooms); + + // Inisialisasi status default (semua OFF) untuk setiap ruangan + const initialStatus: { [roomId: string]: { [deviceName: string]: boolean } } = {}; + dummyRooms.forEach(room => { + initialStatus[`room_${room.id}`] = { + "Lampu 1": false, + "Lampu 2": false, + "AC 1": false, + "Proyektor": false, + }; + }); + setRoomDeviceStatus(initialStatus); }, []); - const handleCutOff = (roomName: string) => { - if(confirm(`PERINGATAN: Anda yakin ingin mematikan daya secara paksa di ${roomName}?`)) { - alert(`Sinyal pemutusan daya dikirim ke Relay Master ${roomName}.`); - // Nanti di sini kamu pasang axios.post ke Golang -> MQTT -> ESP32 + // 2. FUNGSI HANDLE TOGGLE YANG DIPERBAIKI (Menerima roomId) + const handleDeviceToggle = async (roomId: number, roomName: string, deviceName: string) => { + const roomIdKey = `room_${roomId}`; + const currentStatus = roomDeviceStatus[roomIdKey]?.[deviceName] || false; + const actionType = currentStatus ? "off" : "on"; + + const confirmMsg = `Apakah Anda yakin ingin mematikan ${deviceName} di ${roomName}?`; + if (currentStatus && !confirm(confirmMsg)) return; + + let backendDevice = ""; + if (deviceName === 'AC 1') backendDevice = "ac"; + else if (deviceName === 'Proyektor') backendDevice = "projector"; + else if (deviceName === 'Lampu 1') backendDevice = "lampu1"; + else if (deviceName === 'Lampu 2') backendDevice = "lampu2"; + + try { + // Tembak API Golang (Ganti dengan endpoint aslimu jika berbeda) + const response = await fetch("http://localhost:8080/api/hardware/control", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device: backendDevice, action: actionType }), + }); + + if (response.ok) { + // 3. UPDATE STATE HANYA UNTUK RUANGAN YANG DIKLIK + setRoomDeviceStatus(prev => ({ + ...prev, + [roomIdKey]: { + ...prev[roomIdKey], + [deviceName]: !currentStatus, + }, + })); + } else { + const errorData = await response.json(); + alert(`GAGAL: ${errorData.error || response.statusText}`); + } + } catch (error) { + console.error("Error API:", error); + alert("GAGAL: Tidak dapat terhubung ke Server Golang."); } }; - // 2. FUNGSI BARU UNTUK KONTROL PERANGKAT SPESIFIK - const handleDeviceToggle = (roomName: string, deviceName: string) => { - alert(`Mengirim sinyal IoT untuk menyalakan/mematikan [${deviceName}] di [${roomName}]...`); - // Nanti ganti dengan axios.put(`http://localhost:8080/api/rooms/.../device`, { device: deviceName }) + const handleCutOff = (roomName: string) => { + if (confirm(`PERINGATAN: Anda yakin ingin mematikan daya secara paksa di ${roomName}?`)) { + alert(`Sinyal pemutusan daya dikirim ke Relay Master ${roomName}.`); + } }; return (
+ {/* Header section... */}
@@ -44,92 +119,110 @@ export default function PowerMonitoringPage() {
- {/* SUDAH DITAMBAHKAN PENGURUTAN ABJAD AGAR RAPI */} - {[...rooms] - .sort((a, b) => a.name.localeCompare(b.name)) - .map((room) => ( -
- - {/* Indikator Status di Pojok Kanan Atas */} -
- {room.isRelayOn ? 'Sirkuit Aktif' : 'Sirkuit Terputus'} -
+ {[...rooms].sort((a, b) => a.name.localeCompare(b.name)).map((room) => { + + // Ambil status spesifik untuk ruangan ini + const roomIdKey = `room_${room.id}`; + const currentRoomStatus = roomDeviceStatus[roomIdKey] || {}; -

{room.name}

- -
- 1000 ? 'text-orange-500' : 'text-gray-800'}`}> - {room.power} - - Watts -
- - {room.power > 1000 && ( -
- Beban Tinggi Terdeteksi! -
- )} - - {/* 3. PANEL KONTROL IoT BARU */} -
-

IoT Device Control

-
- - - - - -
-
- -
- Update: {room.lastUpdate} + return ( +
- + {/* Status Badge */} +
+ {room.isRelayOn ? 'Sirkuit Aktif' : 'Sirkuit Terputus'} +
+ +

{room.name}

+ + {/* Power display... */} +
+ 1000 ? 'text-orange-500' : 'text-gray-800'}`}> + {room.power} + + Watts +
+ + {room.power > 1000 && ( +
+ Beban Tinggi Terdeteksi! +
+ )} + + {/* 3. PANEL KONTROL IoT BARU DENGAN LOGIKA WARNA DIPERBAIKI */} +
+

IoT Device Control

+
+ + {/* LAMPU 1 */} + + + {/* LAMPU 2 */} + + + {/* AC 1 */} + + + {/* PROYEKTOR */} + + +
+
+ + {/* Footer... */} +
+ Update: {room.lastUpdate} + +
-
- ))} + ); + })}
);