Compare commits
10 Commits
1e792300b5
...
6fc5d105a5
| Author | SHA1 | Date |
|---|---|---|
|
|
6fc5d105a5 | |
|
|
18114de707 | |
|
|
349a275594 | |
|
|
ef9a79df9e | |
|
|
bee56e2cd0 | |
|
|
63ffe9c8ed | |
|
|
ce1b178ae5 | |
|
|
59d89ae7a5 | |
|
|
5dd7cb1e1a | |
|
|
54ab217d2d |
|
|
@ -0,0 +1,12 @@
|
|||
# This is a generated file; do not edit or check into version control.
|
||||
flutter_local_notifications=C:\\Users\\abilh\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\flutter_local_notifications-17.2.4\\
|
||||
flutter_local_notifications_linux=C:\\Users\\abilh\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\flutter_local_notifications_linux-4.0.1\\
|
||||
flutter_native_splash=C:\\Users\\abilh\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\flutter_native_splash-2.4.4\\
|
||||
path_provider_linux=C:\\Users\\abilh\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_linux-2.2.1\\
|
||||
path_provider_windows=C:\\Users\\abilh\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_windows-2.3.0\\
|
||||
shared_preferences=C:\\Users\\abilh\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences-2.5.3\\
|
||||
shared_preferences_android=C:\\Users\\abilh\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_android-2.4.7\\
|
||||
shared_preferences_foundation=C:\\Users\\abilh\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_foundation-2.5.4\\
|
||||
shared_preferences_linux=C:\\Users\\abilh\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_linux-2.4.1\\
|
||||
shared_preferences_web=C:\\Users\\abilh\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_web-2.4.3\\
|
||||
shared_preferences_windows=C:\\Users\\abilh\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_windows-2.4.1\\
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# Changelog - Perbaikan Status dan Notifikasi
|
||||
|
||||
## Tanggal: 25 Juni 2026
|
||||
|
||||
### Masalah yang Diperbaiki
|
||||
|
||||
1. **Status Pengeringan Tetap Berjalan Meskipun Alat Terputus**
|
||||
- Sebelumnya: Aplikasi tidak mendeteksi ketika ESP32 terputus saat pengeringan berlangsung
|
||||
- Sekarang: Sistem mendeteksi timeout (10 detik tanpa data) dan memberikan warning
|
||||
|
||||
2. **Notifikasi Terlalu Banyak**
|
||||
- Sebelumnya: Notifikasi muncul untuk setiap perubahan status (dimulai, ikan terdeteksi, berjalan, selesai)
|
||||
- Sekarang: Notifikasi hanya muncul untuk **Pengeringan Selesai**
|
||||
|
||||
### Perubahan Detail
|
||||
|
||||
#### 1. Dashboard Screen (dashboard_screen.dart)
|
||||
|
||||
**Deteksi Timeout:**
|
||||
- Menambahkan variabel `_lastDataReceived` untuk tracking waktu data terakhir
|
||||
- Menambahkan `_timeoutCheckTimer` yang mengecek setiap 2 detik
|
||||
- Jika tidak ada data > 10 detik saat pengeringan:
|
||||
- Menampilkan pesan "Koneksi ke alat terputus"
|
||||
- Relay UI dimatikan
|
||||
- Status pengeringan tetap dipertahankan untuk konsistensi
|
||||
|
||||
**Reset Status:**
|
||||
- Ketika menerima status `READY` atau `CONNECTED`:
|
||||
- Reset `modePengeringan` = false
|
||||
- Reset `pengeringanSelesai` = false
|
||||
- Reset `beratTersimpan` = false
|
||||
|
||||
**Notifikasi:**
|
||||
- Menghapus notifikasi untuk status `START` (pengeringan dimulai)
|
||||
- Menghapus notifikasi untuk status `BERJALAN` (ikan terdeteksi)
|
||||
- **Mempertahankan** notifikasi untuk status `SELESAI` (pengeringan selesai)
|
||||
|
||||
#### 2. Dashboard Screen With API (dashboard_screen_with_api.dart)
|
||||
|
||||
**Notifikasi:**
|
||||
- Menghapus notifikasi untuk status `READY` (pengeringan dimulai)
|
||||
- Menghapus notifikasi untuk status `BERJALAN` (ikan terdeteksi)
|
||||
- **Mempertahankan** notifikasi untuk status `SELESAI` (pengeringan selesai)
|
||||
|
||||
#### 3. Notification Service (notification_service.dart)
|
||||
|
||||
**Method yang Dihapus:**
|
||||
- `notifyPengeringanDimulai()` - pengeringan dimulai
|
||||
- `notifyIkanTerdeteksi(double berat)` - ikan terdeteksi
|
||||
- `notifyPengeringanBerjalan(double berat, double target)` - pengeringan berjalan
|
||||
|
||||
**Method yang Dipertahankan:**
|
||||
- `notifyPengeringanSelesai(double beratAkhir)` - pengeringan selesai ✅
|
||||
- `notifySuhuTinggi(double suhu)` - peringatan suhu tinggi
|
||||
- `notifyKoneksiTerputus()` - koneksi terputus
|
||||
- `notifyKoneksiTersambung()` - koneksi tersambung
|
||||
|
||||
#### 4. Notification Manager (notification_manager.dart)
|
||||
|
||||
**Method yang Dihapus:**
|
||||
- `notifyPengeringanDimulai()` - pengeringan dimulai
|
||||
- `notifyIkanTerdeteksi(double berat)` - ikan terdeteksi
|
||||
- `notifyPengeringanBerjalan(double berat, double target)` - pengeringan berjalan
|
||||
|
||||
**Method yang Dipertahankan:**
|
||||
- `notifyPengeringanSelesai(double beratAkhir)` - pengeringan selesai ✅
|
||||
|
||||
### Cara Kerja Deteksi Timeout
|
||||
|
||||
```dart
|
||||
// Timer berjalan setiap 2 detik
|
||||
_timeoutCheckTimer = Timer.periodic(const Duration(seconds: 2), (timer) {
|
||||
// Cek apakah sudah > 10 detik tanpa data
|
||||
if (_lastDataReceived != null &&
|
||||
DateTime.now().difference(_lastDataReceived!) > Duration(seconds: 10) &&
|
||||
modePengeringan) {
|
||||
|
||||
// Tampilkan warning
|
||||
lastStatusMessage = 'Koneksi ke alat terputus - Status mungkin tidak akurat';
|
||||
|
||||
// Matikan relay di UI
|
||||
heater = false;
|
||||
fanIn = false;
|
||||
lamp = false;
|
||||
exhaust = false;
|
||||
|
||||
// Status pengeringan tetap dipertahankan
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
**Skenario 1: Alat Terputus Saat Pengeringan**
|
||||
1. Mulai pengeringan
|
||||
2. Cabut koneksi WiFi ESP32 atau matikan alat
|
||||
3. ✅ Setelah 10 detik, aplikasi menampilkan warning
|
||||
4. ✅ Relay di UI otomatis OFF
|
||||
5. ✅ Status pengeringan tetap ditampilkan
|
||||
|
||||
**Skenario 2: Notifikasi**
|
||||
1. Mulai pengeringan
|
||||
2. ❌ TIDAK ada notifikasi "Pengeringan Dimulai"
|
||||
3. Ikan terdeteksi
|
||||
4. ❌ TIDAK ada notifikasi "Ikan Terdeteksi"
|
||||
5. Pengeringan selesai
|
||||
6. ✅ Muncul notifikasi "Pengeringan Selesai"
|
||||
|
||||
### Catatan
|
||||
|
||||
- Timeout 10 detik dipilih karena ESP32 mengirim data setiap 1 detik
|
||||
- 10 detik memberikan buffer yang cukup untuk network latency
|
||||
- Status pengeringan tidak direset otomatis untuk menghindari kehilangan informasi
|
||||
- User harus manual reset dengan tombol RESET setelah koneksi pulih
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
Perubahan ini **tidak mempengaruhi**:
|
||||
- Backend server
|
||||
- ESP32 firmware
|
||||
- MQTT communication
|
||||
- Database structure
|
||||
- API endpoints
|
||||
|
||||
Perubahan **hanya di aplikasi Flutter** untuk:
|
||||
- Deteksi timeout
|
||||
- Filtering notifikasi
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
# 📊 Panduan Fitur Grafik Chart - Aplikasi Pengering Ikan
|
||||
|
||||
## 🎯 Fitur yang Ditambahkan
|
||||
|
||||
Aplikasi sekarang memiliki **sistem grafik chart yang lengkap** untuk monitoring data pengeringan ikan secara visual dan detail.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Komponen Grafik
|
||||
|
||||
### 1. **Dashboard - Grafik Real-time** 📈
|
||||
Tampilan utama dengan 2 grafik real-time yang menampilkan:
|
||||
|
||||
#### **Grafik Suhu**
|
||||
- Menampilkan perubahan suhu secara real-time
|
||||
- Warna: Orange/Red gradient
|
||||
- Range: 20-60°C
|
||||
- Update setiap detik saat menerima data dari ESP32
|
||||
- Menampilkan 20 data terakhir dengan scrolling otomatis
|
||||
|
||||
#### **Grafik Berat**
|
||||
- Menampilkan penurunan berat ikan secara real-time
|
||||
- Warna: Blue/Cyan gradient
|
||||
- Menampilkan garis target berat (hijau putus-putus)
|
||||
- Tooltip menampilkan sisa berat menuju target
|
||||
- Update setiap detik saat menerima data dari ESP32
|
||||
|
||||
**Fitur Grafik Dashboard:**
|
||||
- 🔴 Live indicator - menandakan data real-time
|
||||
- 📍 Dot marker pada titik terakhir
|
||||
- 💧 Gradient fill di bawah garis
|
||||
- 📏 Grid lines untuk kemudahan pembacaan
|
||||
- 🎯 Target line pada grafik berat
|
||||
- 👆 Touch tooltip - tap grafik untuk detail
|
||||
|
||||
---
|
||||
|
||||
### 2. **Chart Screen - History Detail** 📊
|
||||
|
||||
Layar khusus untuk melihat data history dari database dengan detail lengkap.
|
||||
|
||||
#### **Cara Akses:**
|
||||
Klik tab **"Grafik"** di bottom navigation (icon bar_chart)
|
||||
|
||||
#### **Grafik yang Tersedia:**
|
||||
|
||||
**A. Grafik Suhu History**
|
||||
- Menampilkan history suhu dari database
|
||||
- Timeline berupa index data (#1, #2, dst)
|
||||
- Tooltip menampilkan suhu + timestamp
|
||||
- Smooth curve line
|
||||
|
||||
**B. Grafik Berat History**
|
||||
- Menampilkan history berat dari database
|
||||
- Garis target berat horizontal
|
||||
- Tooltip menampilkan berat, target, dan timestamp
|
||||
- Color coding untuk titik di atas/bawah target
|
||||
|
||||
**C. Statistik Card**
|
||||
Menampilkan analisis data:
|
||||
- 📊 Rata-rata Suhu
|
||||
- ⚖️ Rata-rata Berat
|
||||
- 🔺 Max Suhu
|
||||
- 🔻 Min Suhu
|
||||
- 🔺 Max Berat
|
||||
- 🔻 Min Berat
|
||||
|
||||
#### **Filter Data:**
|
||||
Tekan icon filter (⋮) di kanan atas untuk memilih:
|
||||
- 50 Data Terakhir
|
||||
- 100 Data Terakhir
|
||||
- 200 Data Terakhir
|
||||
|
||||
#### **Refresh Data:**
|
||||
- Pull to refresh (tarik ke bawah)
|
||||
- Atau tekan icon refresh di info card
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Visual Features
|
||||
|
||||
### **Color Scheme:**
|
||||
- **Suhu:** Orange → Red (panas)
|
||||
- **Berat:** Blue → Cyan (sejuk)
|
||||
- **Target:** Green (tujuan)
|
||||
- **Live:** Orange dot (status real-time)
|
||||
|
||||
### **Gradient Effects:**
|
||||
- Semua grafik menggunakan gradient fill
|
||||
- Memberikan efek depth dan modern
|
||||
- Smooth transitions
|
||||
|
||||
### **Interactive Elements:**
|
||||
- Touch tooltip - tap grafik untuk detail
|
||||
- Smooth animations
|
||||
- Responsive layout
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Cara Kerja
|
||||
|
||||
### **Dashboard Real-time Chart:**
|
||||
1. Data diterima dari ESP32 via MQTT setiap 1 detik
|
||||
2. Data disimpan ke list `suhuHistory` dan `beratHistory`
|
||||
3. List maksimal 20 data (auto scroll/shift)
|
||||
4. Widget `TemperatureChart` dan `WeightChart` render data
|
||||
5. Chart update otomatis setiap ada data baru
|
||||
|
||||
### **Chart Screen History:**
|
||||
1. Load data dari API endpoint `/api/data/history`
|
||||
2. Parse data menjadi `List<SensorData>`
|
||||
3. Convert ke `List<FlSpot>` untuk grafik
|
||||
4. Hitung statistik (avg, min, max)
|
||||
5. Render dengan `fl_chart` LineChart
|
||||
|
||||
---
|
||||
|
||||
## 📱 User Experience
|
||||
|
||||
### **Dashboard:**
|
||||
- Scroll ke bawah untuk melihat grafik
|
||||
- Grafik selalu update real-time saat pengeringan berjalan
|
||||
- Compact layout untuk quick glance
|
||||
|
||||
### **Chart Screen:**
|
||||
- Full-screen view untuk analisis detail
|
||||
- Filter untuk berbagai range data
|
||||
- Statistik summary untuk insights
|
||||
- Refresh untuk update data terbaru
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Teknologi
|
||||
|
||||
### **Package:**
|
||||
- `fl_chart: ^0.69.0` - Chart library Flutter terbaik
|
||||
- Smooth animations
|
||||
- Touch interactions
|
||||
- Customizable
|
||||
|
||||
### **Widget Structure:**
|
||||
|
||||
```
|
||||
lib/
|
||||
├── screens/
|
||||
│ ├── dashboard_screen.dart (Main screen dengan real-time charts)
|
||||
│ └── chart_screen.dart (History charts screen)
|
||||
└── widgets/
|
||||
├── temperature_chart.dart (Reusable temperature chart widget)
|
||||
└── weight_chart.dart (Reusable weight chart widget)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Data Flow
|
||||
|
||||
### **Real-time:**
|
||||
```
|
||||
ESP32 → MQTT → MqttService → DashboardScreen
|
||||
↓
|
||||
suhuHistory/beratHistory
|
||||
↓
|
||||
TemperatureChart/WeightChart
|
||||
```
|
||||
|
||||
### **History:**
|
||||
```
|
||||
Backend MySQL → API → ApiService → ChartScreen
|
||||
↓
|
||||
List<SensorData>
|
||||
↓
|
||||
fl_chart LineChart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Tips Penggunaan
|
||||
|
||||
1. **Monitoring Real-time:**
|
||||
- Lihat dashboard untuk monitoring cepat
|
||||
- Grafik akan auto-scroll saat data penuh
|
||||
- Tap grafik untuk detail nilai spesifik
|
||||
|
||||
2. **Analisis History:**
|
||||
- Buka Chart Screen untuk analisis mendalam
|
||||
- Gunakan filter untuk berbagai rentang waktu
|
||||
- Perhatikan statistik untuk insights
|
||||
|
||||
3. **Performance:**
|
||||
- Dashboard limit 20 data untuk performa optimal
|
||||
- Chart Screen bisa load hingga 200 data
|
||||
- Refresh jika data tidak update
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Future Improvements
|
||||
|
||||
Beberapa ide untuk pengembangan:
|
||||
|
||||
1. **Export Data:**
|
||||
- Export grafik sebagai image
|
||||
- Export data ke CSV/Excel
|
||||
|
||||
2. **Advanced Analytics:**
|
||||
- Trend analysis
|
||||
- Prediksi waktu selesai
|
||||
- Efisiensi pengeringan
|
||||
|
||||
3. **Comparison:**
|
||||
- Bandingkan multiple sessions
|
||||
- Overlay grafik
|
||||
|
||||
4. **Zoom & Pan:**
|
||||
- Zoom in/out grafik
|
||||
- Pan untuk navigasi
|
||||
|
||||
---
|
||||
|
||||
## ✅ Testing Checklist
|
||||
|
||||
- [x] Grafik suhu tampil real-time
|
||||
- [x] Grafik berat tampil real-time
|
||||
- [x] Target line muncul di grafik berat
|
||||
- [x] Tooltip berfungsi saat tap
|
||||
- [x] Chart screen dapat load history
|
||||
- [x] Filter data berfungsi
|
||||
- [x] Statistik dihitung dengan benar
|
||||
- [x] Refresh data berfungsi
|
||||
- [x] Layout responsive
|
||||
- [x] No memory leaks
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
Jika ada pertanyaan atau bug:
|
||||
1. Check console log untuk error messages
|
||||
2. Pastikan backend API running
|
||||
3. Pastikan MQTT connection aktif
|
||||
4. Verify data di database
|
||||
|
||||
---
|
||||
|
||||
**Selamat menggunakan fitur Grafik Chart! 🎉**
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
# Fix Notifikasi untuk Release Build
|
||||
|
||||
## Masalah
|
||||
Notifikasi tidak muncul pada aplikasi release (APK yang sudah di-install), padahal berfungsi normal saat debug.
|
||||
|
||||
## Penyebab
|
||||
1. **ProGuard/R8 Code Shrinking**: Pada release build, Android secara otomatis menghapus kode yang dianggap tidak digunakan, termasuk komponen notifikasi plugin.
|
||||
2. **Minify Enabled**: Setting minifyEnabled di build.gradle menyebabkan code shrinking aktif.
|
||||
|
||||
## Solusi yang Diterapkan
|
||||
|
||||
### 1. File `proguard-rules.pro` (BARU)
|
||||
File ini berisi rules untuk mencegah ProGuard menghapus kode notifikasi:
|
||||
- Keep Flutter plugin classes
|
||||
- Keep flutter_local_notifications classes
|
||||
- Keep Android notification components
|
||||
- Keep MQTT client classes
|
||||
|
||||
### 2. Update `build.gradle.kts`
|
||||
Menambahkan konfigurasi:
|
||||
```kotlin
|
||||
isMinifyEnabled = false // Disable code shrinking
|
||||
isShrinkResources = false // Disable resource shrinking
|
||||
proguardFiles(...) // ProGuard rules (untuk jika diaktifkan nanti)
|
||||
```
|
||||
|
||||
### 3. Update `MainActivity.kt`
|
||||
Menambahkan pembuatan notification channel saat aplikasi start:
|
||||
- Membuat channel "pengering_ikan_channel"
|
||||
- Set importance ke HIGH
|
||||
- Enable vibration dan lights
|
||||
|
||||
## Cara Build Ulang Aplikasi
|
||||
|
||||
### Option 1: Build APK (Tanpa minify - Recommended)
|
||||
```bash
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter build apk --release
|
||||
```
|
||||
APK akan ada di: `build/app/outputs/flutter-apk/app-release.apk`
|
||||
|
||||
### Option 2: Build APK (Dengan minify + ProGuard)
|
||||
Jika ingin APK lebih kecil, edit `build.gradle.kts`:
|
||||
```kotlin
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
```
|
||||
Lalu build:
|
||||
```bash
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
### Option 3: Build App Bundle (untuk Google Play)
|
||||
```bash
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter build appbundle --release
|
||||
```
|
||||
AAB akan ada di: `build/app/outputs/bundle/release/app-release.aab`
|
||||
|
||||
## Testing Notifikasi
|
||||
|
||||
### 1. Uninstall aplikasi lama
|
||||
```bash
|
||||
adb uninstall com.example.aplikasi_novil
|
||||
```
|
||||
|
||||
### 2. Install APK baru
|
||||
```bash
|
||||
adb install build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
### 3. Test notifikasi
|
||||
- Buka aplikasi
|
||||
- Pastikan permission notifikasi sudah di-allow (Android 13+)
|
||||
- Jalankan proses pengeringan
|
||||
- Tunggu hingga selesai
|
||||
- Notifikasi harus muncul
|
||||
|
||||
### 4. Check via Logcat
|
||||
```bash
|
||||
adb logcat | grep -i notification
|
||||
```
|
||||
|
||||
## Verifikasi Permission
|
||||
|
||||
### Check di Settings Android:
|
||||
1. Buka **Settings > Apps > Pengering Ikan**
|
||||
2. Pilih **Notifications**
|
||||
3. Pastikan notifikasi **ENABLED**
|
||||
4. Check channel "Pengering Ikan" harus ada
|
||||
|
||||
### Check via ADB:
|
||||
```bash
|
||||
adb shell dumpsys notification_listener
|
||||
adb shell cmd notification list
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Notifikasi masih tidak muncul?
|
||||
|
||||
1. **Check permission di runtime:**
|
||||
- Aplikasi harus request permission POST_NOTIFICATIONS untuk Android 13+
|
||||
- Sudah di-handle di `NotificationService.initialize()`
|
||||
|
||||
2. **Check notification channel:**
|
||||
- Channel harus dibuat di native (MainActivity.kt) ✅
|
||||
- Channel ID harus sama dengan yang di Dart ✅
|
||||
|
||||
3. **Check battery optimization:**
|
||||
- Buka Settings > Battery > Battery Optimization
|
||||
- Set aplikasi ke "Don't optimize"
|
||||
|
||||
4. **Check Do Not Disturb:**
|
||||
- Pastikan DND mode tidak aktif
|
||||
- Atau allow aplikasi bypass DND
|
||||
|
||||
5. **Test dengan notification sederhana:**
|
||||
Tambahkan button test notifikasi di UI untuk memastikan sistem notifikasi bekerja.
|
||||
|
||||
## Files yang Diubah/Ditambahkan
|
||||
|
||||
### Baru:
|
||||
- ✅ `android/app/proguard-rules.pro`
|
||||
|
||||
### Diubah:
|
||||
- ✅ `android/app/build.gradle.kts`
|
||||
- ✅ `android/app/src/main/kotlin/com/example/aplikasi_novil/MainActivity.kt`
|
||||
|
||||
### Tidak berubah (sudah benar):
|
||||
- ✅ `android/app/src/main/AndroidManifest.xml` (permission sudah lengkap)
|
||||
- ✅ `lib/services/notification_service.dart` (channel ID sudah benar)
|
||||
|
||||
## Catatan Penting
|
||||
|
||||
1. **Setiap kali build release**, pastikan:
|
||||
- `flutter clean` dulu
|
||||
- `flutter pub get`
|
||||
- Uninstall APK lama sebelum install yang baru
|
||||
|
||||
2. **minifyEnabled = false** akan membuat APK lebih besar (~10-20MB lebih besar)
|
||||
- Tapi notifikasi dijamin bekerja
|
||||
- Jika ingin APK lebih kecil, gunakan minify + ProGuard rules
|
||||
|
||||
3. **Testing harus di device fisik**, bukan emulator
|
||||
- Emulator kadang tidak akurat untuk notifikasi
|
||||
|
||||
4. **Versi Android yang didukung:**
|
||||
- Android 5.0+ (API 21+) untuk notifikasi dasar
|
||||
- Android 8.0+ (API 26+) untuk notification channels
|
||||
- Android 13+ (API 33+) perlu permission POST_NOTIFICATIONS
|
||||
|
||||
## Kontak
|
||||
Jika masih ada masalah, check:
|
||||
1. Logcat untuk error messages
|
||||
2. Notification settings di Android
|
||||
3. Battery optimization settings
|
||||
249
README.md
|
|
@ -1,17 +1,244 @@
|
|||
# aplikasi_novil
|
||||
# 📱 Aplikasi Monitoring Pengering Ikan
|
||||
|
||||
A new Flutter project.
|
||||
Aplikasi mobile untuk monitoring sistem pengering ikan otomatis berbasis IoT dengan ESP32.
|
||||
|
||||
## Getting Started
|
||||
---
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
## 🎯 Fitur Utama
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
- ✅ **Monitoring Real-time** - Data update setiap 5 detik
|
||||
- ✅ **Push Notifications** - Notifikasi status pengeringan
|
||||
- ✅ **Dashboard Lengkap** - Suhu, berat, target, status device
|
||||
- ✅ **Cloud Backend** - Data tersimpan di Railway MySQL
|
||||
- ✅ **Display Only** - Monitoring tanpa kontrol manual
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
---
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
## 🏗️ Arsitektur Sistem
|
||||
|
||||
```
|
||||
ESP32 (Hardware)
|
||||
↓
|
||||
↓ MQTT Publish
|
||||
↓
|
||||
Backend Railway (Node.js)
|
||||
↓
|
||||
↓ Save to Database
|
||||
↓
|
||||
MySQL Database
|
||||
↑
|
||||
↑ API Polling (5 detik)
|
||||
↑
|
||||
Aplikasi Flutter (Android)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Komponen
|
||||
|
||||
### 1. **Hardware (ESP32)**
|
||||
- File: `esp32_pengering_ikan_railway.ino`
|
||||
- Sensor: DHT22 (suhu), HX711 (berat)
|
||||
- Relay: 4 channel (Heater, Fan, Lamp, Exhaust)
|
||||
- Koneksi: WiFi + MQTT
|
||||
|
||||
### 2. **Backend (Node.js)**
|
||||
- Folder: `backend/`
|
||||
- Framework: Express.js
|
||||
- Database: MySQL (Railway)
|
||||
- MQTT Client: Subscribe dari ESP32
|
||||
- REST API: Endpoint untuk aplikasi
|
||||
|
||||
### 3. **Mobile App (Flutter)**
|
||||
- Folder: `lib/`
|
||||
- Platform: Android
|
||||
- Update: API polling 5 detik
|
||||
- Notifikasi: flutter_local_notifications
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Build APK:
|
||||
```bash
|
||||
# Jalankan script build
|
||||
.\build-with-icon.ps1
|
||||
|
||||
# Atau manual
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
### Install di HP:
|
||||
1. Copy `PengeringIkan.apk` dari Desktop
|
||||
2. Install di HP Android
|
||||
3. Izinkan notifikasi
|
||||
4. Buka aplikasi
|
||||
|
||||
---
|
||||
|
||||
## 📱 Tampilan Aplikasi
|
||||
|
||||
### Status Card
|
||||
- Status sistem (READY, SCANNING, BERJALAN, SELESAI)
|
||||
- Waktu mulai & durasi
|
||||
- Pesan status
|
||||
|
||||
### Metrics
|
||||
- 🌡️ Suhu (°C)
|
||||
- ⚖️ Berat (gram)
|
||||
- 🎯 Target berat (gram)
|
||||
|
||||
### Device Status
|
||||
- 🔥 Heater (ON/OFF)
|
||||
- 💨 Fan (ON/OFF)
|
||||
- 🌪️ Exhaust (ON/OFF)
|
||||
|
||||
### Connection Indicator
|
||||
- ☁️🟢 API Connected
|
||||
- ☁️🔴 API Disconnected
|
||||
|
||||
---
|
||||
|
||||
## 🔔 Notifikasi
|
||||
|
||||
Aplikasi mengirim push notification saat:
|
||||
|
||||
1. **Pengeringan Siap** (Status → READY)
|
||||
2. **Ikan Terdeteksi** (Status → BERJALAN)
|
||||
3. **Pengeringan Selesai** (Status → SELESAI)
|
||||
|
||||
Notifikasi muncul di dalam & luar aplikasi.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Konfigurasi
|
||||
|
||||
### Backend URL:
|
||||
```dart
|
||||
// lib/services/api_service.dart
|
||||
static const String baseUrl = 'https://web-production-47eb.up.railway.app';
|
||||
```
|
||||
|
||||
### Polling Interval:
|
||||
```dart
|
||||
// lib/screens/dashboard_screen_with_api.dart
|
||||
Timer.periodic(const Duration(seconds: 5), ...);
|
||||
```
|
||||
|
||||
### MQTT Topics (Backend):
|
||||
```javascript
|
||||
// backend/server.js
|
||||
const TOPIC_DATA = 'novil/pengering/data';
|
||||
const TOPIC_STATUS = 'novil/pengering/status';
|
||||
const TOPIC_CONTROL = 'novil/pengering/control';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
Dokumentasi lengkap: `backend/API_DOCUMENTATION.md`
|
||||
|
||||
### Main Endpoints:
|
||||
- `GET /` - Health check
|
||||
- `GET /api/data/latest` - Data sensor terbaru
|
||||
- `GET /api/data/history` - Riwayat data
|
||||
- `GET /api/status/history` - Riwayat status
|
||||
- `GET /api/stats` - Statistik
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Requirements:
|
||||
- Flutter SDK 3.5+
|
||||
- Android SDK
|
||||
- Node.js 18+
|
||||
- MySQL 8.0+
|
||||
|
||||
### Setup Backend:
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
### Setup Flutter:
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 Dokumentasi
|
||||
|
||||
- **APK_MONITORING_FINAL.md** - Panduan lengkap aplikasi
|
||||
- **PERBAIKAN_APK_FINAL.md** - Changelog & perbaikan
|
||||
- **PERBAIKAN_STATUS_MQTT.md** - Penjelasan API only
|
||||
- **backend/API_DOCUMENTATION.md** - API reference
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Icon Cloud Merah?
|
||||
- Cek koneksi internet HP
|
||||
- Test backend: https://web-production-47eb.up.railway.app/
|
||||
- Restart aplikasi
|
||||
|
||||
### Data Tidak Update?
|
||||
- Pastikan icon cloud hijau
|
||||
- Cek ESP32 status (WiFi connected)
|
||||
- Cek backend logs di Railway
|
||||
|
||||
### Notifikasi Tidak Muncul?
|
||||
- Izinkan notifikasi di Settings
|
||||
- Disable battery optimization
|
||||
- Cek status berubah di ESP32
|
||||
|
||||
---
|
||||
|
||||
## 📝 Version History
|
||||
|
||||
### v1.0.0 (1 Juni 2026) - Current
|
||||
- ✅ Monitoring only (no control)
|
||||
- ✅ API polling 5 detik
|
||||
- ✅ Push notifications
|
||||
- ✅ 3 device cards (Heater, Fan, Exhaust)
|
||||
- ✅ Auto-update data
|
||||
- ✅ Connection indicator
|
||||
|
||||
---
|
||||
|
||||
## 👨💻 Tech Stack
|
||||
|
||||
- **Mobile:** Flutter 3.5, Dart 3.0
|
||||
- **Backend:** Node.js 18, Express.js
|
||||
- **Database:** MySQL 8.0 (Railway)
|
||||
- **IoT:** ESP32, Arduino IDE
|
||||
- **Protocol:** MQTT, REST API
|
||||
- **Hosting:** Railway.app
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
Untuk pertanyaan atau masalah:
|
||||
1. Cek dokumentasi di folder `docs/`
|
||||
2. Lihat troubleshooting di README
|
||||
3. Cek logs di Railway dashboard
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
Private project - All rights reserved
|
||||
|
||||
---
|
||||
|
||||
**Build Date:** 1 Juni 2026
|
||||
**Version:** 1.0.0
|
||||
**Status:** ✅ Production Ready
|
||||
|
||||
🎉 **Aplikasi siap digunakan untuk monitoring pengering ikan!**
|
||||
|
|
|
|||
|
|
@ -8,15 +8,19 @@ plugins {
|
|||
android {
|
||||
namespace = "com.example.aplikasi_novil"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
ndkVersion = "27.0.12077973"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
|
||||
// Suppress obsolete Java version warnings
|
||||
freeCompilerArgs += listOf("-Xlint:-options")
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
|
|
@ -35,6 +39,17 @@ android {
|
|||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
|
||||
// Disable minify and shrink to prevent notification issues
|
||||
// Set to true if you want smaller APK size, but make sure ProGuard rules are correct
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
|
||||
// ProGuard rules (uncomment if you enable minify)
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,3 +57,7 @@ android {
|
|||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
# Flutter Wrapper
|
||||
-keep class io.flutter.app.** { *; }
|
||||
-keep class io.flutter.plugin.** { *; }
|
||||
-keep class io.flutter.util.** { *; }
|
||||
-keep class io.flutter.view.** { *; }
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
|
||||
# Flutter Local Notifications
|
||||
-keep class com.dexterous.** { *; }
|
||||
-keep class com.dexterous.flutterlocalnotifications.** { *; }
|
||||
-dontwarn com.dexterous.flutterlocalnotifications.**
|
||||
|
||||
# AndroidX
|
||||
-keep class androidx.core.app.NotificationCompat { *; }
|
||||
-keep class androidx.core.app.NotificationCompat$** { *; }
|
||||
-keep class androidx.core.app.NotificationManagerCompat { *; }
|
||||
|
||||
# Keep notification channels (Android 8.0+)
|
||||
-keep class android.app.NotificationChannel { *; }
|
||||
-keep class android.app.NotificationManager { *; }
|
||||
|
||||
# MQTT Client
|
||||
-keep class org.eclipse.paho.** { *; }
|
||||
-keep class org.eclipse.paho.client.mqttv3.** { *; }
|
||||
-dontwarn org.eclipse.paho.**
|
||||
|
||||
# Kotlin
|
||||
-keep class kotlin.** { *; }
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-dontwarn kotlin.**
|
||||
-keepclassmembers class **$WhenMappings {
|
||||
<fields>;
|
||||
}
|
||||
-keepclassmembers class kotlin.Metadata {
|
||||
public <methods>;
|
||||
}
|
||||
|
||||
# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
|
||||
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
|
||||
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
||||
|
||||
# With R8 full mode generic signatures are stripped for classes that are not
|
||||
# kept. Suspend functions are wrapped in continuations where the type argument
|
||||
# is used.
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||
|
||||
# Keep native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Keep setters in Views so that animations can still work.
|
||||
-keepclassmembers public class * extends android.view.View {
|
||||
void set*(***);
|
||||
*** get*();
|
||||
}
|
||||
|
||||
# Keep classes with @Keep annotation
|
||||
-keep @androidx.annotation.Keep class * {*;}
|
||||
-keepclasseswithmembers class * {
|
||||
@androidx.annotation.Keep <methods>;
|
||||
}
|
||||
-keepclasseswithmembers class * {
|
||||
@androidx.annotation.Keep <fields>;
|
||||
}
|
||||
-keepclasseswithmembers class * {
|
||||
@androidx.annotation.Keep <init>(...);
|
||||
}
|
||||
|
|
@ -1,8 +1,24 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Permission untuk internet (MQTT) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Permission untuk notifikasi Android 13+ -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<!-- Permission untuk vibration notifikasi -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- Permission untuk receive boot completed (agar app bisa restart service) -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
|
||||
<!-- Permission untuk wake lock (agar notifikasi tetap jalan di background) -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:label="aplikasi_novil"
|
||||
android:label="Pengering Ikan"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
|
@ -11,7 +27,9 @@
|
|||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
|
|
@ -25,6 +43,22 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Receiver untuk boot completed -->
|
||||
<receiver
|
||||
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Receiver untuk scheduled notifications -->
|
||||
<receiver
|
||||
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -1,5 +1,32 @@
|
|||
package com.example.aplikasi_novil
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Create notification channel for Android 8.0+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
// Channel untuk notifikasi pengeringan selesai
|
||||
val channel = NotificationChannel(
|
||||
"pengering_ikan_channel",
|
||||
"Notifikasi Pengering Ikan",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Notifikasi untuk status pengeringan ikan"
|
||||
enableVibration(true)
|
||||
enableLights(true)
|
||||
}
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,12 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<!-- Simple launch background - no splash image -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item android:drawable="@android:color/white" />
|
||||
</layer-list>
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,12 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<!-- Simple launch background - no splash image -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 963 B |
|
After Width: | Height: | Size: 963 B |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -5,6 +5,10 @@
|
|||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#2196F3</color>
|
||||
</resources>
|
||||
|
|
@ -5,6 +5,10 @@
|
|||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
|
|
|||
|
|
@ -14,6 +14,18 @@ rootProject.layout.buildDirectory.value(newBuildDir)
|
|||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
|
||||
// Force all subprojects to use Java 17
|
||||
afterEvaluate {
|
||||
if (project.hasProperty("android")) {
|
||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
|
|
|
|||
|
|
@ -1,2 +1,16 @@
|
|||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
|
||||
# Suppress obsolete Java warnings
|
||||
android.suppressUnsupportedCompileSdk=34
|
||||
org.gradle.warning.mode=none
|
||||
|
||||
# Force Java 17 for all modules
|
||||
# org.gradle.java.home=
|
||||
kotlin.jvm.target.validation.mode=warning
|
||||
|
||||
# Enable R8 full mode (optional, for better optimization)
|
||||
android.enableR8.fullMode=false
|
||||
|
||||
# Suppress Kotlin compiler warnings
|
||||
kotlin.compiler.suppressWarnings=false
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -0,0 +1,37 @@
|
|||
# Server Configuration
|
||||
PORT=3000
|
||||
MQTT_PORT=1883
|
||||
WS_PORT=8883
|
||||
|
||||
# Node Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# =====================================================
|
||||
# MySQL Database Configuration
|
||||
# =====================================================
|
||||
# Option 1: Use DATABASE_URL (Railway format)
|
||||
# DATABASE_URL=mysql://user:password@host:port/database
|
||||
|
||||
# Option 2: Use MYSQL_URL (Alternative format)
|
||||
# MYSQL_URL=mysql://user:password@host:port/database
|
||||
|
||||
# Option 3: Use Railway MySQL Plugin variables
|
||||
# MYSQLHOST=your-host.railway.app
|
||||
# MYSQLPORT=3306
|
||||
# MYSQLUSER=root
|
||||
# MYSQLPASSWORD=your_password
|
||||
# MYSQLDATABASE=railway
|
||||
|
||||
# Option 4: Use custom variables (for local development)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=pengering_ikan
|
||||
|
||||
# =====================================================
|
||||
# NOTES:
|
||||
# - Railway automatically provides DATABASE_URL or MYSQL_* variables
|
||||
# - You don't need to set these manually in Railway
|
||||
# - For local development, use Option 4 (DB_* variables)
|
||||
# =====================================================
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
# 📡 API Documentation - Pengering Ikan Backend
|
||||
|
||||
## 🔌 Koneksi
|
||||
|
||||
### MQTT Broker
|
||||
- **Host:** `broker.hivemq.com` (public) atau Railway URL (jika deploy)
|
||||
- **Port:** `1883` (TCP) atau `8883` (WebSocket)
|
||||
- **Client ID:** `ESP32_PENGERING` (untuk ESP32)
|
||||
|
||||
### REST API
|
||||
- **Base URL:** `https://web-production-47eb.up.railway.app`
|
||||
- **Local:** `http://localhost:3000`
|
||||
|
||||
---
|
||||
|
||||
## 📨 MQTT Topics
|
||||
|
||||
### 1. **novil/pengering/data** (ESP32 → Backend)
|
||||
ESP32 publish data sensor setiap 1 detik saat pengeringan berjalan.
|
||||
|
||||
**Format:**
|
||||
```json
|
||||
{
|
||||
"suhu": 28.5,
|
||||
"berat": 450.0,
|
||||
"target": 315.0
|
||||
}
|
||||
```
|
||||
|
||||
**Field:**
|
||||
- `suhu` (float): Suhu dalam Celsius
|
||||
- `berat` (float): Berat saat ini dalam gram
|
||||
- `target` (float): Target berat akhir dalam gram
|
||||
|
||||
---
|
||||
|
||||
### 2. **novil/pengering/status** (ESP32 → Backend)
|
||||
ESP32 publish status/event penting.
|
||||
|
||||
**Format:** Plain text string
|
||||
|
||||
**Contoh pesan:**
|
||||
- `"ESP32 CONNECTED"` - ESP32 berhasil connect
|
||||
- `"PENGERINGAN SIAP"` - Mode ready
|
||||
- `"PENGERINGAN DIMULAI"` - Pengeringan mulai
|
||||
- `"SCAN BERAT..."` - Sedang scan berat ikan
|
||||
- `"PENGERINGAN BERJALAN - Awal:500g Target:350g"` - Berat tersimpan
|
||||
- `"PENGERINGAN SELESAI"` - Target tercapai
|
||||
- `"DHT ERROR"` - Sensor DHT error
|
||||
|
||||
---
|
||||
|
||||
### 3. **novil/pengering/button** (ESP32 → Backend)
|
||||
ESP32 publish event button press.
|
||||
|
||||
**Format:** Plain text string
|
||||
|
||||
**Contoh pesan:**
|
||||
- `"BUTTON PRESSED"` - Button ditekan
|
||||
- `"START_BUTTON"` - Button untuk start
|
||||
- `"RESET_BUTTON"` - Button untuk reset
|
||||
|
||||
---
|
||||
|
||||
### 4. **novil/pengering/control** (Backend → ESP32)
|
||||
Backend/Flutter publish command untuk kontrol relay dan sistem.
|
||||
|
||||
**Format:** Plain text string
|
||||
|
||||
**Valid Commands:**
|
||||
- `"HEATER_ON"` - Nyalakan heater (RELAY1)
|
||||
- `"HEATER_OFF"` - Matikan heater
|
||||
- `"FAN_ON"` - Nyalakan fan (RELAY2)
|
||||
- `"FAN_OFF"` - Matikan fan
|
||||
- `"LAMP_ON"` - Nyalakan lamp (RELAY3)
|
||||
- `"LAMP_OFF"` - Matikan lamp
|
||||
- `"EXHAUST_ON"` - Nyalakan exhaust (RELAY4)
|
||||
- `"EXHAUST_OFF"` - Matikan exhaust
|
||||
- `"START"` - Mulai pengeringan (dari aplikasi)
|
||||
- `"RESET"` - Reset ke mode ready (dari aplikasi)
|
||||
|
||||
---
|
||||
|
||||
## 🌐 REST API Endpoints
|
||||
|
||||
### 1. **GET /** - Health Check
|
||||
Cek status server.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "OK",
|
||||
"message": "Pengering Ikan Backend Server",
|
||||
"version": "1.0.0",
|
||||
"uptime": 12345.67,
|
||||
"timestamp": "2026-05-28T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. **GET /api/data/latest** - Get Latest Data
|
||||
Ambil data sensor terbaru.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"suhu": 28.5,
|
||||
"berat": 450.0,
|
||||
"target": 315.0,
|
||||
"relay1": true,
|
||||
"relay2": true,
|
||||
"relay3": true,
|
||||
"relay4": false,
|
||||
"status": "RUNNING",
|
||||
"timestamp": "2026-05-28T10:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Values:**
|
||||
- `CONNECTED` - ESP32 terhubung
|
||||
- `READY` - Siap untuk pengeringan
|
||||
- `RUNNING` - Pengeringan berjalan
|
||||
- `SCANNING` - Sedang scan berat
|
||||
- `COMPLETED` - Pengeringan selesai
|
||||
- `ERROR` - Ada error
|
||||
|
||||
---
|
||||
|
||||
### 3. **GET /api/data/history?limit=50** - Get Data History
|
||||
Ambil riwayat data sensor.
|
||||
|
||||
**Query Parameters:**
|
||||
- `limit` (optional): Jumlah data (default: 50)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"suhu": 28.5,
|
||||
"berat": 450.0,
|
||||
"target": 315.0,
|
||||
"relay1": true,
|
||||
"relay2": true,
|
||||
"relay3": true,
|
||||
"relay4": false,
|
||||
"status": "RUNNING",
|
||||
"timestamp": "2026-05-28 10:30:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. **GET /api/status/history?limit=50** - Get Status History
|
||||
Ambil riwayat status/event.
|
||||
|
||||
**Query Parameters:**
|
||||
- `limit` (optional): Jumlah data (default: 50)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"message": "PENGERINGAN DIMULAI",
|
||||
"timestamp": "2026-05-28 10:30:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. **POST /api/control** - Send Control Command
|
||||
Kirim command ke ESP32 via MQTT.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"command": "HEATER_ON"
|
||||
}
|
||||
```
|
||||
|
||||
**Valid Commands:**
|
||||
- `HEATER_ON`, `HEATER_OFF`
|
||||
- `FAN_ON`, `FAN_OFF`
|
||||
- `LAMP_ON`, `LAMP_OFF`
|
||||
- `EXHAUST_ON`, `EXHAUST_OFF`
|
||||
- `START` (mulai pengeringan)
|
||||
- `RESET` (reset ke ready)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Command sent successfully",
|
||||
"command": "HEATER_ON"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. **GET /api/stats** - Get Statistics
|
||||
Ambil statistik server dan database.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"stats": {
|
||||
"connectedClients": 2,
|
||||
"sensorDataCount": 1234,
|
||||
"statusHistoryCount": 567,
|
||||
"controlCommandsCount": 89,
|
||||
"latestData": { ... },
|
||||
"uptime": 12345.67,
|
||||
"timestamp": "2026-05-28T10:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. **DELETE /api/history/clear** - Clear History
|
||||
Hapus semua riwayat data.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "History cleared successfully"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. **POST /api/database/init** - Initialize Database
|
||||
Buat tabel database (hanya perlu sekali).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Database tables initialized successfully"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. **GET /api/database/test** - Test Database Connection
|
||||
Test koneksi ke MySQL.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Database connection successful"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Flow Komunikasi
|
||||
|
||||
### Skenario 1: Pengeringan Normal (via Button ESP32)
|
||||
|
||||
1. **ESP32 Connect**
|
||||
- ESP32 → MQTT: `novil/pengering/status` = `"ESP32 CONNECTED"`
|
||||
- Backend: Update status = `CONNECTED`
|
||||
|
||||
2. **User Tekan Button (Ready → Start)**
|
||||
- ESP32 → MQTT: `novil/pengering/button` = `"START_BUTTON"`
|
||||
- ESP32 → MQTT: `novil/pengering/status` = `"PENGERINGAN DIMULAI"`
|
||||
- Backend: Update status = `RUNNING`
|
||||
|
||||
3. **Scan Berat Ikan**
|
||||
- ESP32 → MQTT: `novil/pengering/status` = `"SCAN BERAT..."`
|
||||
- Backend: Update status = `SCANNING`
|
||||
|
||||
4. **Berat Tersimpan**
|
||||
- ESP32 → MQTT: `novil/pengering/status` = `"PENGERINGAN BERJALAN - Awal:500g Target:350g"`
|
||||
- Backend: Update status = `RUNNING`
|
||||
|
||||
5. **Kirim Data Sensor (setiap 1 detik)**
|
||||
- ESP32 → MQTT: `novil/pengering/data` = `{"suhu":28.5,"berat":450,"target":315}`
|
||||
- Backend: Simpan ke database
|
||||
|
||||
6. **Target Tercapai**
|
||||
- ESP32 → MQTT: `novil/pengering/status` = `"PENGERINGAN SELESAI"`
|
||||
- Backend: Update status = `COMPLETED`
|
||||
|
||||
7. **User Tekan Button (Selesai → Ready)**
|
||||
- ESP32 → MQTT: `novil/pengering/button` = `"RESET_BUTTON"`
|
||||
- ESP32 → MQTT: `novil/pengering/status` = `"PENGERINGAN SIAP"`
|
||||
- Backend: Update status = `READY`
|
||||
|
||||
---
|
||||
|
||||
### Skenario 2: Kontrol dari Flutter App
|
||||
|
||||
1. **Flutter Subscribe MQTT**
|
||||
- Subscribe: `novil/pengering/data`
|
||||
- Subscribe: `novil/pengering/status`
|
||||
- Terima data real-time
|
||||
|
||||
2. **Flutter Kirim Command START**
|
||||
- Flutter → API: `POST /api/control` body: `{"command":"START"}`
|
||||
- Backend → MQTT: `novil/pengering/control` = `"START"`
|
||||
- ESP32: Terima command, mulai pengeringan
|
||||
|
||||
3. **Flutter Kirim Command HEATER_ON**
|
||||
- Flutter → API: `POST /api/control` body: `{"command":"HEATER_ON"}`
|
||||
- Backend → MQTT: `novil/pengering/control` = `"HEATER_ON"`
|
||||
- ESP32: Nyalakan RELAY1
|
||||
|
||||
4. **Flutter Get Latest Data**
|
||||
- Flutter → API: `GET /api/data/latest`
|
||||
- Backend: Return data terbaru
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Testing dengan cURL
|
||||
|
||||
### Test Health Check
|
||||
```bash
|
||||
curl https://web-production-47eb.up.railway.app/
|
||||
```
|
||||
|
||||
### Test Get Latest Data
|
||||
```bash
|
||||
curl https://web-production-47eb.up.railway.app/api/data/latest
|
||||
```
|
||||
|
||||
### Test Send Command
|
||||
```bash
|
||||
curl -X POST https://web-production-47eb.up.railway.app/api/control \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command":"HEATER_ON"}'
|
||||
```
|
||||
|
||||
### Test Get History
|
||||
```bash
|
||||
curl https://web-production-47eb.up.railway.app/api/data/history?limit=10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Catatan Penting
|
||||
|
||||
1. **ESP32 hanya menggunakan MQTT**, tidak ada HTTP request ke Railway
|
||||
2. **Flutter bisa menggunakan:**
|
||||
- MQTT untuk real-time data (subscribe topics)
|
||||
- REST API untuk kontrol dan history
|
||||
3. **Backend menerima data dari ESP32 via MQTT** dan menyimpan ke MySQL
|
||||
4. **Semua command dari Flutter dikirim via REST API**, backend forward ke ESP32 via MQTT
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deploy ke Railway
|
||||
|
||||
Setelah perubahan, push ke GitHub:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
git add .
|
||||
git commit -m "Update backend untuk ESP32 MQTT integration"
|
||||
git push
|
||||
```
|
||||
|
||||
Railway akan auto-deploy! ✅
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
# Cara Push Backend ke GitHub
|
||||
|
||||
## 📌 **Cara 1: Menggunakan Script PowerShell (RECOMMENDED)**
|
||||
|
||||
### Push dengan pesan custom:
|
||||
```powershell
|
||||
cd backend
|
||||
.\push.ps1 "Fix bug di notifikasi"
|
||||
```
|
||||
|
||||
### Push dengan pesan default:
|
||||
```powershell
|
||||
cd backend
|
||||
.\push.ps1
|
||||
```
|
||||
(Akan menggunakan pesan default: "Update backend")
|
||||
|
||||
---
|
||||
|
||||
## 📌 **Cara 2: Menggunakan Git Alias**
|
||||
|
||||
### Setup (Hanya sekali):
|
||||
```powershell
|
||||
cd backend
|
||||
git config --local alias.quick '!f() { git add -A && git commit -m "$1" && git push origin main; }; f'
|
||||
```
|
||||
|
||||
### Penggunaan:
|
||||
```powershell
|
||||
cd backend
|
||||
git quick "Fix bug di notifikasi"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📌 **Cara 3: Manual (Standard Git)**
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
git add -A
|
||||
git commit -m "Pesan commit Anda"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 **Repository Backend**
|
||||
- **GitHub**: https://github.com/novil04/backend_aplikasi_novil
|
||||
- **Railway**: Auto-deploy setelah push (2-5 menit)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ **Catatan Penting**
|
||||
|
||||
1. **Selalu bekerja di folder backend**
|
||||
```powershell
|
||||
cd c:\Users\abilh\aplikasi_novil\backend
|
||||
```
|
||||
|
||||
2. **Cek status sebelum push**
|
||||
```powershell
|
||||
git status
|
||||
```
|
||||
|
||||
3. **Lihat history commit**
|
||||
```powershell
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
4. **Jangan lupa file .env**
|
||||
- File `.env` TIDAK akan ter-push (sudah di .gitignore)
|
||||
- Atur variabel environment langsung di Railway dashboard
|
||||
|
||||
5. **Cek Railway setelah push**
|
||||
- Buka Railway dashboard
|
||||
- Tunggu build & deploy selesai
|
||||
- Cek logs jika ada error
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **Quick Reference**
|
||||
|
||||
| Tujuan | Command |
|
||||
|--------|---------|
|
||||
| Push cepat | `.\push.ps1 "pesan"` |
|
||||
| Cek perubahan | `git status` |
|
||||
| Lihat log | `git log --oneline -5` |
|
||||
| Batal commit terakhir | `git reset --soft HEAD~1` |
|
||||
| Pull update terbaru | `git pull origin main` |
|
||||
|
||||
---
|
||||
|
||||
## 🆘 **Troubleshooting**
|
||||
|
||||
### Error: "Push rejected"
|
||||
```powershell
|
||||
# Pull dulu, lalu push lagi
|
||||
git pull origin main
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Error: "Permission denied"
|
||||
```powershell
|
||||
# Pastikan sudah login GitHub
|
||||
# Atau gunakan Personal Access Token
|
||||
```
|
||||
|
||||
### Script tidak jalan
|
||||
```powershell
|
||||
# Enable execution policy (run as Administrator)
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```
|
||||
|
|
@ -0,0 +1 @@
|
|||
web: node server.js
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# aplikasi_novil
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
# 🔧 SOLUSI: Data ESP32 Tidak Masuk ke MySQL Railway
|
||||
|
||||
## 🎯 Masalah
|
||||
|
||||
ESP32 sudah publish data ke MQTT, tapi data **tidak masuk ke MySQL** di Railway.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Penyebab
|
||||
|
||||
### Arsitektur yang Salah:
|
||||
|
||||
```
|
||||
❌ SEBELUM:
|
||||
|
||||
ESP32 → HiveMQ (broker.hivemq.com)
|
||||
↓
|
||||
(data hilang di sini)
|
||||
|
||||
Backend Railway (Aedes Broker Internal - DISABLED)
|
||||
↓
|
||||
MySQL (tidak ada data)
|
||||
```
|
||||
|
||||
**Masalah:**
|
||||
1. ESP32 publish ke **HiveMQ** public broker
|
||||
2. Backend Railway menggunakan **Aedes** (MQTT broker internal)
|
||||
3. Aedes **DISABLED** di Railway (tidak bisa bind port 1883)
|
||||
4. Backend **TIDAK connect** ke HiveMQ
|
||||
5. Backend **TIDAK subscribe** ke topic ESP32
|
||||
6. **Data hilang** karena tidak ada yang terima
|
||||
|
||||
---
|
||||
|
||||
## ✅ Solusi
|
||||
|
||||
### Arsitektur yang Benar:
|
||||
|
||||
```
|
||||
✅ SESUDAH:
|
||||
|
||||
ESP32 → HiveMQ (broker.hivemq.com)
|
||||
↓
|
||||
Backend Railway (MQTT Client)
|
||||
↓ (subscribe & save)
|
||||
MySQL Database
|
||||
```
|
||||
|
||||
**Perubahan:**
|
||||
1. Backend connect ke **HiveMQ sebagai MQTT Client**
|
||||
2. Backend **subscribe** ke topic ESP32
|
||||
3. Backend **terima data** dari ESP32
|
||||
4. Backend **save ke MySQL**
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Perubahan yang Sudah Dilakukan
|
||||
|
||||
### 1. **Install Package MQTT Client**
|
||||
```bash
|
||||
npm install mqtt
|
||||
```
|
||||
|
||||
### 2. **Update `server.js`**
|
||||
- Tambah import `mqtt` client
|
||||
- Connect ke HiveMQ sebagai client
|
||||
- Subscribe ke topic: `novil/pengering/data`, `status`, `button`
|
||||
- Handle message dan save ke MySQL
|
||||
- Publish command via HiveMQ
|
||||
|
||||
### 3. **Update `.env`**
|
||||
- Tambah `MQTT_BROKER_URL=mqtt://broker.hivemq.com:1883`
|
||||
- Tambah template untuk MySQL credentials
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Cara Deploy
|
||||
|
||||
### 1. **Test Lokal (Optional)**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
✅ Connected to MQTT Broker (HiveMQ)
|
||||
✅ Subscribed to topics:
|
||||
- novil/pengering/data
|
||||
- novil/pengering/status
|
||||
- novil/pengering/button
|
||||
🚀 REST API running on port 3000
|
||||
```
|
||||
|
||||
### 2. **Push ke GitHub**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
git add .
|
||||
git commit -m "Fix: Backend connect to HiveMQ as MQTT client"
|
||||
git push
|
||||
```
|
||||
|
||||
### 3. **Configure Railway**
|
||||
|
||||
Di Railway Dashboard:
|
||||
|
||||
1. Buka project → **Variables**
|
||||
2. Tambahkan variable baru:
|
||||
- **Key:** `MQTT_BROKER_URL`
|
||||
- **Value:** `mqtt://broker.hivemq.com:1883`
|
||||
3. MySQL credentials sudah otomatis dari Railway MySQL Plugin (tidak perlu diubah)
|
||||
|
||||
### 4. **Wait for Auto-Deploy**
|
||||
|
||||
Railway akan otomatis deploy setelah push ke GitHub.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### 1. **Check MQTT Connection**
|
||||
|
||||
```bash
|
||||
curl https://your-app.railway.app/api/stats
|
||||
```
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"stats": {
|
||||
"mqttConnected": true, // ← HARUS TRUE
|
||||
"sensorDataCount": 0,
|
||||
"statusHistoryCount": 0,
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Jika `mqttConnected: false`:**
|
||||
- Check Railway logs
|
||||
- Pastikan `MQTT_BROKER_URL` sudah ditambahkan
|
||||
- Redeploy
|
||||
|
||||
---
|
||||
|
||||
### 2. **Test dengan ESP32**
|
||||
|
||||
1. **Upload sketch ESP32** (tidak perlu diubah)
|
||||
2. **Buka Serial Monitor** (115200 baud)
|
||||
3. **Tekan button** untuk start pengeringan
|
||||
4. **Tunggu 5 detik** (scan berat)
|
||||
5. **Check Serial Monitor:**
|
||||
```
|
||||
MQTT Data Sent: {"suhu":28.5,"berat":450,"target":315}
|
||||
MQTT Data Sent: {"suhu":28.6,"berat":448,"target":315}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. **Check Data di MySQL**
|
||||
|
||||
```bash
|
||||
curl https://your-app.railway.app/api/data/latest
|
||||
```
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"suhu": 28.5,
|
||||
"berat": 450.0,
|
||||
"target": 315.0,
|
||||
"status": "RUNNING",
|
||||
"timestamp": "2026-05-28T10:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Jika data masih kosong:**
|
||||
- Check Railway logs untuk error
|
||||
- Pastikan ESP32 sudah publish data
|
||||
- Check MySQL credentials
|
||||
|
||||
---
|
||||
|
||||
### 4. **Check History**
|
||||
|
||||
```bash
|
||||
curl https://your-app.railway.app/api/data/history?limit=10
|
||||
```
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 10,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"suhu": 28.5,
|
||||
"berat": 450.0,
|
||||
"target": 315.0,
|
||||
"relay1": true,
|
||||
"relay2": true,
|
||||
"relay3": true,
|
||||
"relay4": false,
|
||||
"status": "RUNNING",
|
||||
"timestamp": "2026-05-28 10:30:00"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. **Test Send Command**
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-app.railway.app/api/control \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command":"HEATER_ON"}'
|
||||
```
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Command sent successfully",
|
||||
"command": "HEATER_ON"
|
||||
}
|
||||
```
|
||||
|
||||
**Check ESP32 Serial Monitor:**
|
||||
```
|
||||
MQTT Message [novil/pengering/control] : HEATER_ON
|
||||
HEATER ON
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Railway Logs
|
||||
|
||||
Di Railway Dashboard → Deployments → View Logs
|
||||
|
||||
**Expected Logs:**
|
||||
```
|
||||
🔄 Connecting to MQTT Broker: mqtt://broker.hivemq.com:1883
|
||||
✅ Connected to MQTT Broker (HiveMQ)
|
||||
✅ Subscribed to topics:
|
||||
- novil/pengering/data
|
||||
- novil/pengering/status
|
||||
- novil/pengering/button
|
||||
|
||||
📨 MQTT Message received:
|
||||
Topic: novil/pengering/data
|
||||
Message: {"suhu":28.5,"berat":450,"target":315}
|
||||
✅ Data saved to MySQL database
|
||||
|
||||
📨 MQTT Message received:
|
||||
Topic: novil/pengering/status
|
||||
Message: PENGERINGAN DIMULAI
|
||||
✅ Status saved to MySQL database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Problem 1: `mqttConnected: false`
|
||||
|
||||
**Penyebab:** Backend tidak bisa connect ke HiveMQ
|
||||
|
||||
**Solusi:**
|
||||
1. Check Railway logs untuk error message
|
||||
2. Pastikan variable `MQTT_BROKER_URL` sudah ditambahkan
|
||||
3. Pastikan value: `mqtt://broker.hivemq.com:1883` (bukan `http://`)
|
||||
4. Redeploy
|
||||
|
||||
---
|
||||
|
||||
### Problem 2: Data masih tidak masuk ke MySQL
|
||||
|
||||
**Penyebab:** Database credentials salah atau database belum diinit
|
||||
|
||||
**Solusi:**
|
||||
|
||||
1. **Test database connection:**
|
||||
```bash
|
||||
curl https://your-app.railway.app/api/database/test
|
||||
```
|
||||
|
||||
2. **Initialize database tables:**
|
||||
```bash
|
||||
curl -X POST https://your-app.railway.app/api/database/init
|
||||
```
|
||||
|
||||
3. **Check MySQL credentials di Railway:**
|
||||
- `MYSQLHOST`
|
||||
- `MYSQLPORT`
|
||||
- `MYSQLUSER`
|
||||
- `MYSQLPASSWORD`
|
||||
- `MYSQLDATABASE`
|
||||
|
||||
---
|
||||
|
||||
### Problem 3: ESP32 tidak terima command
|
||||
|
||||
**Penyebab:** ESP32 tidak subscribe atau MQTT disconnect
|
||||
|
||||
**Solusi:**
|
||||
|
||||
1. **Check ESP32 Serial Monitor:**
|
||||
```
|
||||
MQTT Connected
|
||||
Subscribed to: novil/pengering/control
|
||||
```
|
||||
|
||||
2. **Restart ESP32**
|
||||
|
||||
3. **Check WiFi connection**
|
||||
|
||||
---
|
||||
|
||||
### Problem 4: Railway logs error "Cannot find module 'mqtt'"
|
||||
|
||||
**Penyebab:** Package `mqtt` belum terinstall
|
||||
|
||||
**Solusi:**
|
||||
|
||||
1. **Pastikan `package.json` sudah update:**
|
||||
```json
|
||||
"dependencies": {
|
||||
"mqtt": "^5.3.5",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
2. **Push ke GitHub lagi:**
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "Add mqtt package"
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
### Pre-Deploy:
|
||||
- [x] Package `mqtt` sudah ditambahkan di `package.json`
|
||||
- [x] `server.js` sudah diupdate dengan MQTT client
|
||||
- [x] `.env` sudah ada template MySQL credentials
|
||||
- [x] `npm install` berhasil
|
||||
|
||||
### Deploy:
|
||||
- [ ] Push ke GitHub
|
||||
- [ ] Tambahkan `MQTT_BROKER_URL` di Railway Variables
|
||||
- [ ] Wait for auto-deploy (2-3 menit)
|
||||
- [ ] Check Railway logs untuk "Connected to MQTT Broker"
|
||||
|
||||
### Testing:
|
||||
- [ ] Test `/api/stats` → `mqttConnected: true`
|
||||
- [ ] Upload ESP32 sketch
|
||||
- [ ] Test ESP32 publish data
|
||||
- [ ] Test `/api/data/latest` → ada data
|
||||
- [ ] Test `/api/data/history` → ada history
|
||||
- [ ] Test send command → ESP32 terima
|
||||
|
||||
---
|
||||
|
||||
## 📝 Catatan Penting
|
||||
|
||||
1. **ESP32 tidak perlu diubah** - sketch tetap sama
|
||||
2. **MySQL credentials** sudah otomatis dari Railway MySQL Plugin
|
||||
3. **HiveMQ** adalah public broker, gratis, tidak perlu registrasi
|
||||
4. **Backend sekarang sebagai MQTT Client**, bukan broker
|
||||
5. **Aedes broker tetap ada** tapi disabled (untuk future use)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Expected Result
|
||||
|
||||
Setelah deploy:
|
||||
|
||||
1. ✅ Backend connect ke HiveMQ
|
||||
2. ✅ Backend subscribe ke topic ESP32
|
||||
3. ✅ ESP32 publish data → Backend terima
|
||||
4. ✅ Backend save data ke MySQL
|
||||
5. ✅ Data history tersimpan
|
||||
6. ✅ Status history tersimpan
|
||||
7. ✅ Command dari Flutter → ESP32 berfungsi
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
Jika masih ada masalah:
|
||||
|
||||
1. **Check Railway Logs** untuk error detail
|
||||
2. **Check ESP32 Serial Monitor** untuk MQTT status
|
||||
3. **Test API endpoints** untuk verify data
|
||||
4. **Baca dokumentasi lengkap** di `backend/FIX_MQTT_MYSQL.md`
|
||||
|
||||
---
|
||||
|
||||
Selamat mencoba! 🚀🎉
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
-- =====================================================
|
||||
-- SCRIPT UNTUK MEMBUAT TABEL DI RAILWAY MYSQL
|
||||
-- Copy-paste script ini ke Railway MySQL Query
|
||||
-- =====================================================
|
||||
|
||||
-- Table: sensor_data
|
||||
CREATE TABLE IF NOT EXISTS sensor_data (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
suhu FLOAT NOT NULL,
|
||||
berat FLOAT NOT NULL,
|
||||
target FLOAT NOT NULL,
|
||||
relay1 BOOLEAN DEFAULT FALSE,
|
||||
relay2 BOOLEAN DEFAULT FALSE,
|
||||
relay3 BOOLEAN DEFAULT FALSE,
|
||||
relay4 BOOLEAN DEFAULT FALSE,
|
||||
status VARCHAR(50) DEFAULT 'DISCONNECTED',
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_timestamp (timestamp)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Table: status_history
|
||||
CREATE TABLE IF NOT EXISTS status_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
message TEXT NOT NULL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_timestamp (timestamp)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Table: control_commands
|
||||
CREATE TABLE IF NOT EXISTS control_commands (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
command VARCHAR(50) NOT NULL,
|
||||
source VARCHAR(50) DEFAULT 'API',
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_timestamp (timestamp)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Verify tables created
|
||||
SHOW TABLES;
|
||||
|
||||
-- Check table structure
|
||||
DESCRIBE sensor_data;
|
||||
DESCRIBE status_history;
|
||||
DESCRIBE control_commands;
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
// =====================================================
|
||||
// DATABASE CONFIGURATION
|
||||
// =====================================================
|
||||
// Railway provides different variable names, support multiple formats:
|
||||
// 1. Railway MySQL Plugin: MYSQL_URL, MYSQLHOST, MYSQLPORT, MYSQLUSER, MYSQLPASSWORD, MYSQLDATABASE
|
||||
// 2. Custom variables: DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME
|
||||
// 3. Standard: DATABASE_URL
|
||||
|
||||
// Parse DATABASE_URL if provided (format: mysql://user:password@host:port/database)
|
||||
let dbConfig = {};
|
||||
|
||||
if (process.env.DATABASE_URL) {
|
||||
try {
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
dbConfig = {
|
||||
host: url.hostname,
|
||||
port: parseInt(url.port) || 3306,
|
||||
user: url.username,
|
||||
password: url.password,
|
||||
database: url.pathname.slice(1), // Remove leading '/'
|
||||
};
|
||||
console.log('📦 Using DATABASE_URL for connection');
|
||||
} catch (error) {
|
||||
console.error('❌ Error parsing DATABASE_URL:', error.message);
|
||||
}
|
||||
} else if (process.env.MYSQL_URL) {
|
||||
try {
|
||||
const url = new URL(process.env.MYSQL_URL);
|
||||
dbConfig = {
|
||||
host: url.hostname,
|
||||
port: parseInt(url.port) || 3306,
|
||||
user: url.username,
|
||||
password: url.password,
|
||||
database: url.pathname.slice(1),
|
||||
};
|
||||
console.log('📦 Using MYSQL_URL for connection');
|
||||
} catch (error) {
|
||||
console.error('❌ Error parsing MYSQL_URL:', error.message);
|
||||
}
|
||||
} else {
|
||||
// Use individual environment variables
|
||||
dbConfig = {
|
||||
host: process.env.MYSQLHOST || process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.MYSQLPORT || process.env.DB_PORT || '3306'),
|
||||
user: process.env.MYSQLUSER || process.env.DB_USER || 'root',
|
||||
password: process.env.MYSQLPASSWORD || process.env.DB_PASSWORD || '',
|
||||
database: process.env.MYSQLDATABASE || process.env.DB_NAME || 'pengering_ikan',
|
||||
};
|
||||
console.log('📦 Using individual environment variables for connection');
|
||||
}
|
||||
|
||||
// Add connection pool settings
|
||||
dbConfig.waitForConnections = true;
|
||||
dbConfig.connectionLimit = 10;
|
||||
dbConfig.queueLimit = 0;
|
||||
|
||||
// Log configuration (hide password)
|
||||
console.log('🔧 Database Config:', {
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.user,
|
||||
database: dbConfig.database,
|
||||
password: dbConfig.password ? '***' : '(empty)'
|
||||
});
|
||||
|
||||
// Create connection pool
|
||||
const pool = mysql.createPool(dbConfig);
|
||||
|
||||
// Test connection
|
||||
async function testConnection() {
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
console.log('✅ Database connected successfully');
|
||||
connection.release();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Database connection failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize database tables
|
||||
async function initDatabase() {
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
// Create sensor_data table
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS sensor_data (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
suhu FLOAT NOT NULL,
|
||||
berat FLOAT NOT NULL,
|
||||
target FLOAT NOT NULL,
|
||||
relay1 BOOLEAN DEFAULT FALSE,
|
||||
relay2 BOOLEAN DEFAULT FALSE,
|
||||
relay3 BOOLEAN DEFAULT FALSE,
|
||||
relay4 BOOLEAN DEFAULT FALSE,
|
||||
status VARCHAR(50) DEFAULT 'DISCONNECTED',
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_timestamp (timestamp)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
// Create status_history table
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS status_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
message TEXT NOT NULL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_timestamp (timestamp)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
// Create control_commands table
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS control_commands (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
command VARCHAR(50) NOT NULL,
|
||||
source VARCHAR(50) DEFAULT 'API',
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_timestamp (timestamp)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
console.log('✅ Database tables initialized');
|
||||
connection.release();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Database initialization failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert sensor data
|
||||
async function insertSensorData(data) {
|
||||
try {
|
||||
// Convert ISO timestamp to MySQL DATETIME format
|
||||
let mysqlTimestamp;
|
||||
if (data.timestamp) {
|
||||
const date = new Date(data.timestamp);
|
||||
mysqlTimestamp = date.toISOString().slice(0, 19).replace('T', ' ');
|
||||
} else {
|
||||
const date = new Date();
|
||||
mysqlTimestamp = date.toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
// Convert boolean to integer (1 or 0) for MySQL
|
||||
const relay1 = data.relay1 ? 1 : 0;
|
||||
const relay2 = data.relay2 ? 1 : 0;
|
||||
const relay3 = data.relay3 ? 1 : 0;
|
||||
const relay4 = data.relay4 ? 1 : 0;
|
||||
|
||||
// Debug logging
|
||||
console.log('📝 Inserting sensor data:');
|
||||
console.log(' Raw relay values:', {
|
||||
relay1_raw: data.relay1,
|
||||
relay2_raw: data.relay2,
|
||||
relay3_raw: data.relay3,
|
||||
relay4_raw: data.relay4
|
||||
});
|
||||
console.log(' Converted relay values:', { relay1, relay2, relay3, relay4 });
|
||||
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO sensor_data (suhu, berat, target, relay1, relay2, relay3, relay4, status, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
data.suhu || 0,
|
||||
data.berat || 0,
|
||||
data.target || 0,
|
||||
relay1,
|
||||
relay2,
|
||||
relay3,
|
||||
relay4,
|
||||
data.status || 'DISCONNECTED',
|
||||
mysqlTimestamp
|
||||
]
|
||||
);
|
||||
return result.insertId;
|
||||
} catch (error) {
|
||||
console.error('❌ Error inserting sensor data:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get latest sensor data
|
||||
async function getLatestSensorData() {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
id, suhu, berat, target, relay1, relay2, relay3, relay4, status,
|
||||
CONVERT_TZ(timestamp, '+00:00', '+07:00') as timestamp
|
||||
FROM sensor_data
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1`
|
||||
);
|
||||
return rows[0] || null;
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting latest sensor data:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get sensor data history
|
||||
async function getSensorDataHistory(limit = 50) {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
id, suhu, berat, target, relay1, relay2, relay3, relay4, status,
|
||||
CONVERT_TZ(timestamp, '+00:00', '+07:00') as timestamp
|
||||
FROM sensor_data
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?`,
|
||||
[limit]
|
||||
);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting sensor data history:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert status history
|
||||
async function insertStatusHistory(message) {
|
||||
try {
|
||||
// Convert to MySQL DATETIME format
|
||||
const date = new Date();
|
||||
const mysqlTimestamp = date.toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO status_history (message, timestamp) VALUES (?, ?)',
|
||||
[message, mysqlTimestamp]
|
||||
);
|
||||
return result.insertId;
|
||||
} catch (error) {
|
||||
console.error('❌ Error inserting status history:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get status history
|
||||
async function getStatusHistory(limit = 50) {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
id, message,
|
||||
CONVERT_TZ(timestamp, '+00:00', '+07:00') as timestamp
|
||||
FROM status_history
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?`,
|
||||
[limit]
|
||||
);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting status history:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert control command
|
||||
async function insertControlCommand(command, source = 'API') {
|
||||
try {
|
||||
// Convert to MySQL DATETIME format
|
||||
const date = new Date();
|
||||
const mysqlTimestamp = date.toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO control_commands (command, source, timestamp) VALUES (?, ?, ?)',
|
||||
[command, source, mysqlTimestamp]
|
||||
);
|
||||
return result.insertId;
|
||||
} catch (error) {
|
||||
console.error('❌ Error inserting control command:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear old data (keep last N records)
|
||||
async function clearOldData(keepRecords = 1000) {
|
||||
try {
|
||||
await pool.query(`
|
||||
DELETE FROM sensor_data
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM sensor_data ORDER BY timestamp DESC LIMIT ?
|
||||
) AS temp
|
||||
)
|
||||
`, [keepRecords]);
|
||||
|
||||
await pool.query(`
|
||||
DELETE FROM status_history
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM status_history ORDER BY timestamp DESC LIMIT ?
|
||||
) AS temp
|
||||
)
|
||||
`, [keepRecords]);
|
||||
|
||||
console.log(`✅ Old data cleared, kept last ${keepRecords} records`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Error clearing old data:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get statistics
|
||||
async function getStatistics() {
|
||||
try {
|
||||
const [sensorCount] = await pool.query('SELECT COUNT(*) as count FROM sensor_data');
|
||||
const [statusCount] = await pool.query('SELECT COUNT(*) as count FROM status_history');
|
||||
const [commandCount] = await pool.query('SELECT COUNT(*) as count FROM control_commands');
|
||||
|
||||
return {
|
||||
sensorDataCount: sensorCount[0].count,
|
||||
statusHistoryCount: statusCount[0].count,
|
||||
controlCommandsCount: commandCount[0].count
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting statistics:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
pool,
|
||||
testConnection,
|
||||
initDatabase,
|
||||
insertSensorData,
|
||||
getLatestSensorData,
|
||||
getSensorDataHistory,
|
||||
insertStatusHistory,
|
||||
getStatusHistory,
|
||||
insertControlCommand,
|
||||
clearOldData,
|
||||
getStatistics
|
||||
};
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
// =====================================================
|
||||
// DATABASE MIGRATION SCRIPT
|
||||
// Migrate data dari Railway lama ke Railway baru
|
||||
// =====================================================
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
// Konfigurasi Database LAMA (Railway account lama)
|
||||
const oldDB = {
|
||||
host: process.env.OLD_DB_HOST || 'mysql.railway.internal',
|
||||
port: process.env.OLD_DB_PORT || 3306,
|
||||
user: process.env.OLD_DB_USER || 'root',
|
||||
password: process.env.OLD_DB_PASSWORD || '',
|
||||
database: process.env.OLD_DB_NAME || 'railway'
|
||||
};
|
||||
|
||||
// Konfigurasi Database BARU (Railway account baru)
|
||||
const newDB = {
|
||||
host: process.env.NEW_DB_HOST || 'mysql.railway.internal',
|
||||
port: process.env.NEW_DB_PORT || 3306,
|
||||
user: process.env.NEW_DB_USER || 'root',
|
||||
password: process.env.NEW_DB_PASSWORD || '',
|
||||
database: process.env.NEW_DB_NAME || 'railway'
|
||||
};
|
||||
|
||||
async function migrateData() {
|
||||
let oldConnection, newConnection;
|
||||
|
||||
try {
|
||||
console.log('🔄 Connecting to OLD database...');
|
||||
oldConnection = await mysql.createConnection(oldDB);
|
||||
console.log('✅ Connected to OLD database');
|
||||
|
||||
console.log('🔄 Connecting to NEW database...');
|
||||
newConnection = await mysql.createConnection(newDB);
|
||||
console.log('✅ Connected to NEW database');
|
||||
|
||||
// =====================================================
|
||||
// 1. Migrate sensor_data
|
||||
// =====================================================
|
||||
console.log('\n📊 Migrating sensor_data...');
|
||||
const [sensorData] = await oldConnection.query('SELECT * FROM sensor_data ORDER BY id');
|
||||
console.log(` Found ${sensorData.length} records`);
|
||||
|
||||
for (const row of sensorData) {
|
||||
await newConnection.query(
|
||||
`INSERT INTO sensor_data (suhu, berat, target, relay1, relay2, relay3, relay4, status, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[row.suhu, row.berat, row.target, row.relay1, row.relay2, row.relay3, row.relay4, row.status, row.timestamp]
|
||||
);
|
||||
}
|
||||
console.log('✅ sensor_data migrated');
|
||||
|
||||
// =====================================================
|
||||
// 2. Migrate status_history
|
||||
// =====================================================
|
||||
console.log('\n📝 Migrating status_history...');
|
||||
const [statusHistory] = await oldConnection.query('SELECT * FROM status_history ORDER BY id');
|
||||
console.log(` Found ${statusHistory.length} records`);
|
||||
|
||||
for (const row of statusHistory) {
|
||||
await newConnection.query(
|
||||
`INSERT INTO status_history (message, timestamp) VALUES (?, ?)`,
|
||||
[row.message, row.timestamp]
|
||||
);
|
||||
}
|
||||
console.log('✅ status_history migrated');
|
||||
|
||||
// =====================================================
|
||||
// 3. Migrate control_commands
|
||||
// =====================================================
|
||||
console.log('\n🎛️ Migrating control_commands...');
|
||||
const [controlCommands] = await oldConnection.query('SELECT * FROM control_commands ORDER BY id');
|
||||
console.log(` Found ${controlCommands.length} records`);
|
||||
|
||||
for (const row of controlCommands) {
|
||||
await newConnection.query(
|
||||
`INSERT INTO control_commands (command, source, timestamp) VALUES (?, ?, ?)`,
|
||||
[row.command, row.source, row.timestamp]
|
||||
);
|
||||
}
|
||||
console.log('✅ control_commands migrated');
|
||||
|
||||
console.log('\n🎉 MIGRATION COMPLETED SUCCESSFULLY!');
|
||||
console.log('=====================================');
|
||||
console.log(`Total sensor_data: ${sensorData.length}`);
|
||||
console.log(`Total status_history: ${statusHistory.length}`);
|
||||
console.log(`Total control_commands: ${controlCommands.length}`);
|
||||
console.log('=====================================');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Migration Error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (oldConnection) await oldConnection.end();
|
||||
if (newConnection) await newConnection.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration
|
||||
console.log('🚀 Starting Database Migration...');
|
||||
console.log('=====================================');
|
||||
migrateData()
|
||||
.then(() => {
|
||||
console.log('✅ Migration script finished');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "pengering-ikan-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend server untuk sistem pengering ikan dengan MQTT broker dan REST API",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"keywords": [
|
||||
"mqtt",
|
||||
"iot",
|
||||
"pengering-ikan",
|
||||
"esp32"
|
||||
],
|
||||
"author": "Novil",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"aedes": "^0.51.3",
|
||||
"mqtt": "^5.3.5",
|
||||
"ws": "^8.16.0",
|
||||
"websocket-stream": "^5.5.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"morgan": "^1.10.0",
|
||||
"mysql2": "^3.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# Script untuk push perubahan backend ke GitHub
|
||||
# Usage: .\push.ps1 "commit message"
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Message = "Update backend"
|
||||
)
|
||||
|
||||
Write-Host "🔄 Pushing backend to GitHub..." -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Check if there are changes
|
||||
$status = git status --porcelain
|
||||
if ([string]::IsNullOrEmpty($status)) {
|
||||
Write-Host "✅ No changes to commit" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Show changes
|
||||
Write-Host "📝 Changes detected:" -ForegroundColor Yellow
|
||||
git status --short
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "📦 Adding all changes..." -ForegroundColor Cyan
|
||||
git add -A
|
||||
|
||||
Write-Host "💾 Committing with message: '$Message'" -ForegroundColor Cyan
|
||||
git commit -m $Message
|
||||
|
||||
Write-Host "🚀 Pushing to GitHub (backend_aplikasi_novil)..." -ForegroundColor Cyan
|
||||
git push origin main
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host ""
|
||||
Write-Host "✅ Successfully pushed to GitHub!" -ForegroundColor Green
|
||||
Write-Host "🔗 Repository: https://github.com/novil04/backend_aplikasi_novil" -ForegroundColor Cyan
|
||||
Write-Host "⏳ Railway will auto-deploy in 2-5 minutes" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "❌ Failed to push to GitHub" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[build]
|
||||
builder = "NIXPACKS"
|
||||
|
||||
[deploy]
|
||||
startCommand = "node server.js"
|
||||
restartPolicyType = "ON_FAILURE"
|
||||
restartPolicyMaxRetries = 10
|
||||
|
||||
# Force rebuild for relay fix v1.0.3
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
-- =====================================================
|
||||
-- Database Schema untuk Pengering Ikan IoT
|
||||
-- =====================================================
|
||||
|
||||
-- Create database (optional, Railway sudah provide)
|
||||
-- CREATE DATABASE IF NOT EXISTS pengering_ikan;
|
||||
-- USE pengering_ikan;
|
||||
|
||||
-- =====================================================
|
||||
-- Table: sensor_data
|
||||
-- Menyimpan data sensor dari ESP32
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS sensor_data (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
suhu FLOAT NOT NULL COMMENT 'Suhu dalam Celsius',
|
||||
berat FLOAT NOT NULL COMMENT 'Berat dalam gram',
|
||||
target FLOAT NOT NULL COMMENT 'Target berat dalam gram',
|
||||
relay1 BOOLEAN DEFAULT FALSE COMMENT 'Status Relay 1 (Heater)',
|
||||
relay2 BOOLEAN DEFAULT FALSE COMMENT 'Status Relay 2 (Fan)',
|
||||
relay3 BOOLEAN DEFAULT FALSE COMMENT 'Status Relay 3 (Lamp)',
|
||||
relay4 BOOLEAN DEFAULT FALSE COMMENT 'Status Relay 4 (Exhaust)',
|
||||
status VARCHAR(50) DEFAULT 'DISCONNECTED' COMMENT 'Status koneksi ESP32',
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Waktu data diterima',
|
||||
INDEX idx_timestamp (timestamp),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Data sensor dari ESP32';
|
||||
|
||||
-- =====================================================
|
||||
-- Table: status_history
|
||||
-- Menyimpan history status dari ESP32
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS status_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
message TEXT NOT NULL COMMENT 'Status message dari ESP32',
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Waktu status diterima',
|
||||
INDEX idx_timestamp (timestamp)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='History status dari ESP32';
|
||||
|
||||
-- =====================================================
|
||||
-- Table: control_commands
|
||||
-- Menyimpan history control commands
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS control_commands (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
command VARCHAR(50) NOT NULL COMMENT 'Command yang dikirim (HEATER_ON, FAN_OFF, dll)',
|
||||
source VARCHAR(50) DEFAULT 'API' COMMENT 'Sumber command (API, MQTT, Manual)',
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Waktu command dikirim',
|
||||
INDEX idx_timestamp (timestamp),
|
||||
INDEX idx_command (command)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='History control commands';
|
||||
|
||||
-- =====================================================
|
||||
-- Sample Data (Optional, untuk testing)
|
||||
-- =====================================================
|
||||
|
||||
-- Insert sample sensor data
|
||||
INSERT INTO sensor_data (suhu, berat, target, relay1, relay2, relay3, relay4, status) VALUES
|
||||
(25.5, 1000.0, 800.0, true, true, false, false, 'CONNECTED'),
|
||||
(26.0, 950.0, 800.0, true, true, false, false, 'CONNECTED'),
|
||||
(26.5, 900.0, 800.0, true, true, false, false, 'CONNECTED'),
|
||||
(27.0, 850.0, 800.0, true, true, false, false, 'CONNECTED'),
|
||||
(27.5, 800.0, 800.0, false, false, false, false, 'COMPLETED');
|
||||
|
||||
-- Insert sample status history
|
||||
INSERT INTO status_history (message) VALUES
|
||||
('ESP32 Connected'),
|
||||
('Drying process started'),
|
||||
('Temperature: 25.5°C, Weight: 1000g'),
|
||||
('Temperature: 27.5°C, Weight: 800g'),
|
||||
('Drying process completed');
|
||||
|
||||
-- Insert sample control commands
|
||||
INSERT INTO control_commands (command, source) VALUES
|
||||
('HEATER_ON', 'API'),
|
||||
('FAN_ON', 'API'),
|
||||
('HEATER_OFF', 'API'),
|
||||
('FAN_OFF', 'API');
|
||||
|
||||
-- =====================================================
|
||||
-- Useful Queries
|
||||
-- =====================================================
|
||||
|
||||
-- Get latest sensor data
|
||||
-- SELECT * FROM sensor_data ORDER BY timestamp DESC LIMIT 1;
|
||||
|
||||
-- Get data history (last 50 records)
|
||||
-- SELECT * FROM sensor_data ORDER BY timestamp DESC LIMIT 50;
|
||||
|
||||
-- Get status history (last 50 records)
|
||||
-- SELECT * FROM status_history ORDER BY timestamp DESC LIMIT 50;
|
||||
|
||||
-- Get control commands history
|
||||
-- SELECT * FROM control_commands ORDER BY timestamp DESC LIMIT 50;
|
||||
|
||||
-- Get statistics
|
||||
-- SELECT
|
||||
-- COUNT(*) as total_records,
|
||||
-- MIN(suhu) as min_temp,
|
||||
-- MAX(suhu) as max_temp,
|
||||
-- AVG(suhu) as avg_temp,
|
||||
-- MIN(berat) as min_weight,
|
||||
-- MAX(berat) as max_weight,
|
||||
-- AVG(berat) as avg_weight
|
||||
-- FROM sensor_data;
|
||||
|
||||
-- Get data by date range
|
||||
-- SELECT * FROM sensor_data
|
||||
-- WHERE timestamp BETWEEN '2024-01-01' AND '2024-01-31'
|
||||
-- ORDER BY timestamp DESC;
|
||||
|
||||
-- Delete old data (keep last 1000 records)
|
||||
-- DELETE FROM sensor_data
|
||||
-- WHERE id NOT IN (
|
||||
-- SELECT id FROM (
|
||||
-- SELECT id FROM sensor_data ORDER BY timestamp DESC LIMIT 1000
|
||||
-- ) AS temp
|
||||
-- );
|
||||
|
||||
-- =====================================================
|
||||
-- Maintenance Queries
|
||||
-- =====================================================
|
||||
|
||||
-- Check table sizes
|
||||
-- SELECT
|
||||
-- table_name AS 'Table',
|
||||
-- ROUND(((data_length + index_length) / 1024 / 1024), 2) AS 'Size (MB)'
|
||||
-- FROM information_schema.TABLES
|
||||
-- WHERE table_schema = 'pengering_ikan'
|
||||
-- ORDER BY (data_length + index_length) DESC;
|
||||
|
||||
-- Optimize tables
|
||||
-- OPTIMIZE TABLE sensor_data;
|
||||
-- OPTIMIZE TABLE status_history;
|
||||
-- OPTIMIZE TABLE control_commands;
|
||||
|
||||
-- Backup database (via mysqldump)
|
||||
-- mysqldump -u root -p pengering_ikan > backup.sql
|
||||
|
||||
-- Restore database
|
||||
-- mysql -u root -p pengering_ikan < backup.sql
|
||||
|
|
@ -0,0 +1,961 @@
|
|||
console.log('🔄 SERVER STARTING - Version 1.0.3-relay-fix - ' + new Date().toISOString());
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const morgan = require('morgan');
|
||||
const mqtt = require('mqtt');
|
||||
const aedes = require('aedes')();
|
||||
const { createServer } = require('net');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const http = require('http');
|
||||
require('dotenv').config();
|
||||
|
||||
// Import database functions
|
||||
const db = require('./database');
|
||||
|
||||
// =====================================================
|
||||
// CONFIGURATION
|
||||
// =====================================================
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const MQTT_PORT = process.env.MQTT_PORT || 1883;
|
||||
const WS_PORT = process.env.WS_PORT || 8883;
|
||||
|
||||
// MQTT Client Configuration (untuk connect ke HiveMQ)
|
||||
const MQTT_BROKER_URL = process.env.MQTT_BROKER_URL || 'mqtt://broker.hivemq.com:1883';
|
||||
const MQTT_TOPICS = {
|
||||
data: 'novil/pengering/data',
|
||||
status: 'novil/pengering/status',
|
||||
button: 'novil/pengering/button',
|
||||
control: 'novil/pengering/control'
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// EXPRESS APP
|
||||
// =====================================================
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(morgan('dev'));
|
||||
|
||||
// =====================================================
|
||||
// DATA STORAGE (MySQL Database)
|
||||
// =====================================================
|
||||
let latestData = {
|
||||
suhu: 0,
|
||||
berat: 0,
|
||||
target: 0,
|
||||
relay1: false,
|
||||
relay2: false,
|
||||
relay3: false,
|
||||
relay4: false,
|
||||
status: 'DISCONNECTED',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Last data received timestamp
|
||||
let lastDataTimestamp = Date.now();
|
||||
|
||||
// Connection timeout (5 minutes = 300 seconds)
|
||||
const CONNECTION_TIMEOUT = 300000;
|
||||
|
||||
// Check connection status periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastData = now - lastDataTimestamp;
|
||||
|
||||
// If no data received for more than CONNECTION_TIMEOUT, mark as disconnected
|
||||
if (timeSinceLastData > CONNECTION_TIMEOUT && latestData.status !== 'DISCONNECTED') {
|
||||
console.log('⚠️ No data received for', Math.floor(timeSinceLastData / 1000), 'seconds');
|
||||
console.log(' Marking as DISCONNECTED');
|
||||
latestData.status = 'DISCONNECTED';
|
||||
// Optionally reset relay states
|
||||
latestData.relay1 = false;
|
||||
latestData.relay2 = false;
|
||||
latestData.relay3 = false;
|
||||
latestData.relay4 = false;
|
||||
}
|
||||
}, 5000); // Check every 5 seconds
|
||||
|
||||
// Initialize database on startup
|
||||
(async () => {
|
||||
try {
|
||||
console.log('🔄 Initializing database connection...');
|
||||
const connected = await db.testConnection();
|
||||
if (connected) {
|
||||
console.log('✅ Database connected, initializing tables...');
|
||||
await db.initDatabase();
|
||||
// Load latest data from database
|
||||
try {
|
||||
const latest = await db.getLatestSensorData();
|
||||
if (latest) {
|
||||
latestData = {
|
||||
suhu: latest.suhu,
|
||||
berat: latest.berat,
|
||||
target: latest.target,
|
||||
relay1: latest.relay1,
|
||||
relay2: latest.relay2,
|
||||
relay3: latest.relay3,
|
||||
relay4: latest.relay4,
|
||||
status: latest.status,
|
||||
timestamp: latest.timestamp
|
||||
};
|
||||
console.log('✅ Latest data loaded from database');
|
||||
}
|
||||
} catch (loadError) {
|
||||
console.warn('⚠️ Could not load latest data:', loadError.message);
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ Running without database connection');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Database initialization error:', error.message);
|
||||
console.warn('⚠️ Continuing without database...');
|
||||
}
|
||||
})();
|
||||
|
||||
// =====================================================
|
||||
// MQTT CLIENT (Connect to HiveMQ)
|
||||
// =====================================================
|
||||
console.log('🔄 Connecting to MQTT Broker:', MQTT_BROKER_URL);
|
||||
|
||||
const mqttClient = mqtt.connect(MQTT_BROKER_URL, {
|
||||
clientId: `backend_${Math.random().toString(16).slice(2, 10)}`,
|
||||
clean: true,
|
||||
connectTimeout: 4000,
|
||||
reconnectPeriod: 1000,
|
||||
keepalive: 60
|
||||
});
|
||||
|
||||
mqttClient.on('connect', () => {
|
||||
console.log('✅ Connected to MQTT Broker (HiveMQ)');
|
||||
|
||||
// Subscribe to all topics
|
||||
mqttClient.subscribe([
|
||||
MQTT_TOPICS.data,
|
||||
MQTT_TOPICS.status,
|
||||
MQTT_TOPICS.button,
|
||||
MQTT_TOPICS.control
|
||||
], (err) => {
|
||||
if (err) {
|
||||
console.error('❌ Failed to subscribe:', err);
|
||||
} else {
|
||||
console.log('✅ Subscribed to topics:');
|
||||
console.log(' -', MQTT_TOPICS.data);
|
||||
console.log(' -', MQTT_TOPICS.status);
|
||||
console.log(' -', MQTT_TOPICS.button);
|
||||
console.log(' -', MQTT_TOPICS.control);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
mqttClient.on('error', (error) => {
|
||||
console.error('❌ MQTT Client Error:', error.message);
|
||||
});
|
||||
|
||||
mqttClient.on('reconnect', () => {
|
||||
console.log('🔄 Reconnecting to MQTT Broker...');
|
||||
});
|
||||
|
||||
mqttClient.on('offline', () => {
|
||||
console.warn('⚠️ MQTT Client offline');
|
||||
});
|
||||
|
||||
mqttClient.on('message', async (topic, message) => {
|
||||
const msg = message.toString();
|
||||
console.log(`📨 MQTT Message received:`);
|
||||
console.log(` Topic: ${topic}`);
|
||||
console.log(` Message: ${msg}`);
|
||||
|
||||
// =====================================================
|
||||
// TOPIC: novil/pengering/data
|
||||
// Format: {"suhu":28.5,"berat":450,"target":315,"relay1":true,"relay2":true,"relay3":true,"relay4":false}
|
||||
// =====================================================
|
||||
if (topic === MQTT_TOPICS.data) {
|
||||
try {
|
||||
const data = JSON.parse(msg);
|
||||
|
||||
// Debug: log parsed data
|
||||
console.log('📦 Parsed MQTT data:', JSON.stringify(data, null, 2));
|
||||
console.log(' Data type check:', {
|
||||
relay1_type: typeof data.relay1,
|
||||
relay1_value: data.relay1,
|
||||
relay2_type: typeof data.relay2,
|
||||
relay2_value: data.relay2,
|
||||
relay3_type: typeof data.relay3,
|
||||
relay3_value: data.relay3,
|
||||
relay4_type: typeof data.relay4,
|
||||
relay4_value: data.relay4
|
||||
});
|
||||
|
||||
// Get current time in WIB (UTC+7)
|
||||
const now = new Date();
|
||||
const wibTime = new Date(now.getTime() + (7 * 60 * 60 * 1000));
|
||||
|
||||
// Update last data timestamp
|
||||
lastDataTimestamp = Date.now();
|
||||
|
||||
// Helper function to map ESP32 status to backend status
|
||||
const mapStatus = (esp32Status) => {
|
||||
if (!esp32Status) return latestData.status;
|
||||
const statusMap = {
|
||||
'SELESAI': 'COMPLETED',
|
||||
'BERJALAN': 'RUNNING',
|
||||
'SCANNING': 'SCANNING',
|
||||
'READY': 'READY',
|
||||
'CONNECTED': 'CONNECTED',
|
||||
'ERROR': 'ERROR'
|
||||
};
|
||||
return statusMap[esp32Status.toUpperCase()] || esp32Status;
|
||||
};
|
||||
|
||||
// Update latestData dengan data dari ESP32 (termasuk status relay)
|
||||
// Use explicit check for undefined/null to handle false values correctly
|
||||
latestData = {
|
||||
suhu: data.suhu || 0,
|
||||
berat: data.berat || 0,
|
||||
target: data.target || 0,
|
||||
relay1: (data.relay1 !== undefined && data.relay1 !== null) ? Boolean(data.relay1) : latestData.relay1,
|
||||
relay2: (data.relay2 !== undefined && data.relay2 !== null) ? Boolean(data.relay2) : latestData.relay2,
|
||||
relay3: (data.relay3 !== undefined && data.relay3 !== null) ? Boolean(data.relay3) : latestData.relay3,
|
||||
relay4: (data.relay4 !== undefined && data.relay4 !== null) ? Boolean(data.relay4) : latestData.relay4,
|
||||
status: mapStatus(data.status), // Map ESP32 status to backend status
|
||||
timestamp: wibTime.toISOString()
|
||||
};
|
||||
|
||||
console.log(' Final latestData relay values:', {
|
||||
relay1: latestData.relay1,
|
||||
relay2: latestData.relay2,
|
||||
relay3: latestData.relay3,
|
||||
relay4: latestData.relay4,
|
||||
status: latestData.status
|
||||
});
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
await db.insertSensorData(latestData);
|
||||
console.log('✅ Data saved to MySQL database');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save to database:', dbError.message);
|
||||
}
|
||||
|
||||
console.log('✅ Data updated:', latestData);
|
||||
} catch (e) {
|
||||
console.error('❌ Error parsing data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// TOPIC: novil/pengering/status
|
||||
// Format: "ESP32 CONNECTED", "PENGERINGAN DIMULAI", dll
|
||||
// =====================================================
|
||||
if (topic === MQTT_TOPICS.status) {
|
||||
// Update status di latestData
|
||||
if (msg.includes('ESP32 CONNECTED')) {
|
||||
latestData.status = 'CONNECTED';
|
||||
} else if (msg.includes('PENGERINGAN SIAP')) {
|
||||
latestData.status = 'READY';
|
||||
} else if (msg.includes('PENGERINGAN DIMULAI')) {
|
||||
latestData.status = 'RUNNING';
|
||||
} else if (msg.includes('PENGERINGAN BERJALAN')) {
|
||||
latestData.status = 'RUNNING';
|
||||
} else if (msg.includes('PENGERINGAN SELESAI')) {
|
||||
latestData.status = 'COMPLETED';
|
||||
} else if (msg.includes('SCAN BERAT')) {
|
||||
latestData.status = 'SCANNING';
|
||||
} else if (msg.includes('DHT ERROR')) {
|
||||
latestData.status = 'ERROR';
|
||||
}
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
await db.insertStatusHistory(msg);
|
||||
console.log('✅ Status saved to MySQL database');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save status to database:', dbError.message);
|
||||
}
|
||||
console.log('📊 Status:', msg);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// TOPIC: novil/pengering/button
|
||||
// Format: "BUTTON PRESSED", "START_BUTTON", "RESET_BUTTON"
|
||||
// =====================================================
|
||||
if (topic === MQTT_TOPICS.button) {
|
||||
console.log('🔘 Button Event:', msg);
|
||||
|
||||
// Save button event to status history
|
||||
try {
|
||||
await db.insertStatusHistory(`BUTTON: ${msg}`);
|
||||
console.log('✅ Button event saved to MySQL database');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save button event:', dbError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// TOPIC: novil/pengering/control
|
||||
// Format: "STATUS:HEATER_ON", "HEATER_ON", "FAN_OFF", dll
|
||||
// =====================================================
|
||||
if (topic === MQTT_TOPICS.control) {
|
||||
console.log('🎛️ Control Message:', msg);
|
||||
|
||||
// Jika pesan dimulai dengan "STATUS:", ini adalah feedback dari ESP32
|
||||
if (msg.startsWith('STATUS:')) {
|
||||
const statusMsg = msg.replace('STATUS:', '');
|
||||
console.log('📊 Relay Status Update:', statusMsg);
|
||||
|
||||
// Update relay state di latestData
|
||||
if (statusMsg === 'HEATER_ON') latestData.relay1 = true;
|
||||
else if (statusMsg === 'HEATER_OFF') latestData.relay1 = false;
|
||||
else if (statusMsg === 'FAN_ON') latestData.relay2 = true;
|
||||
else if (statusMsg === 'FAN_OFF') latestData.relay2 = false;
|
||||
else if (statusMsg === 'EXHAUST_ON') latestData.relay3 = true;
|
||||
else if (statusMsg === 'EXHAUST_OFF') latestData.relay3 = false;
|
||||
|
||||
// Save status update to status_history
|
||||
try {
|
||||
await db.insertStatusHistory(`RELAY: ${statusMsg}`);
|
||||
console.log('✅ Relay status saved to status_history');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save relay status:', dbError.message);
|
||||
}
|
||||
|
||||
// Save juga ke control_commands dengan source ESP32
|
||||
try {
|
||||
await db.insertControlCommand(statusMsg, 'ESP32');
|
||||
console.log('✅ Relay status saved to control_commands');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save to control_commands:', dbError.message);
|
||||
}
|
||||
} else {
|
||||
// Ini adalah command dari API/Flutter, simpan ke control_commands
|
||||
try {
|
||||
await db.insertControlCommand(msg, 'MQTT');
|
||||
console.log('✅ Control command saved to control_commands');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save control command:', dbError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// MQTT BROKER (AEDES)
|
||||
// =====================================================
|
||||
const mqttServer = createServer(aedes.handle);
|
||||
|
||||
// MQTT Event Handlers
|
||||
aedes.on('client', (client) => {
|
||||
console.log(`📱 Client Connected: ${client.id}`);
|
||||
});
|
||||
|
||||
aedes.on('clientDisconnect', (client) => {
|
||||
console.log(`📴 Client Disconnected: ${client.id}`);
|
||||
});
|
||||
|
||||
aedes.on('publish', async (packet, client) => {
|
||||
if (client) {
|
||||
const topic = packet.topic;
|
||||
const message = packet.payload.toString();
|
||||
|
||||
console.log(`📨 Message from ${client.id}:`);
|
||||
console.log(` Topic: ${topic}`);
|
||||
console.log(` Message: ${message}`);
|
||||
|
||||
// =====================================================
|
||||
// TOPIC: novil/pengering/data
|
||||
// Format: {"suhu":28.5,"berat":450,"target":315,"relay1":true,"relay2":true,"relay3":true,"relay4":false}
|
||||
// =====================================================
|
||||
if (topic === 'novil/pengering/data') {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
// Update last data timestamp
|
||||
lastDataTimestamp = Date.now();
|
||||
|
||||
// Helper function to map ESP32 status to backend status
|
||||
const mapStatus = (esp32Status) => {
|
||||
if (!esp32Status) return latestData.status;
|
||||
const statusMap = {
|
||||
'SELESAI': 'COMPLETED',
|
||||
'BERJALAN': 'RUNNING',
|
||||
'SCANNING': 'SCANNING',
|
||||
'READY': 'READY',
|
||||
'CONNECTED': 'CONNECTED',
|
||||
'ERROR': 'ERROR'
|
||||
};
|
||||
return statusMap[esp32Status.toUpperCase()] || esp32Status;
|
||||
};
|
||||
|
||||
// Update latestData dengan data dari ESP32 (termasuk status relay)
|
||||
// Use explicit check for undefined/null to handle false values correctly
|
||||
latestData = {
|
||||
suhu: data.suhu || 0,
|
||||
berat: data.berat || 0,
|
||||
target: data.target || 0,
|
||||
relay1: (data.relay1 !== undefined && data.relay1 !== null) ? Boolean(data.relay1) : latestData.relay1,
|
||||
relay2: (data.relay2 !== undefined && data.relay2 !== null) ? Boolean(data.relay2) : latestData.relay2,
|
||||
relay3: (data.relay3 !== undefined && data.relay3 !== null) ? Boolean(data.relay3) : latestData.relay3,
|
||||
relay4: (data.relay4 !== undefined && data.relay4 !== null) ? Boolean(data.relay4) : latestData.relay4,
|
||||
status: mapStatus(data.status), // Map ESP32 status to backend status
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
await db.insertSensorData(latestData);
|
||||
console.log('✅ Data saved to database');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save to database:', dbError.message);
|
||||
}
|
||||
|
||||
console.log('✅ Data updated:', latestData);
|
||||
} catch (e) {
|
||||
console.error('❌ Error parsing data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// TOPIC: novil/pengering/status
|
||||
// Format: "ESP32 CONNECTED", "PENGERINGAN DIMULAI", dll
|
||||
// =====================================================
|
||||
if (topic === 'novil/pengering/status') {
|
||||
// Update status di latestData
|
||||
if (message.includes('ESP32 CONNECTED')) {
|
||||
latestData.status = 'CONNECTED';
|
||||
} else if (message.includes('PENGERINGAN SIAP')) {
|
||||
latestData.status = 'READY';
|
||||
} else if (message.includes('PENGERINGAN DIMULAI')) {
|
||||
latestData.status = 'RUNNING';
|
||||
} else if (message.includes('PENGERINGAN BERJALAN')) {
|
||||
latestData.status = 'RUNNING';
|
||||
} else if (message.includes('PENGERINGAN SELESAI')) {
|
||||
latestData.status = 'COMPLETED';
|
||||
} else if (message.includes('SCAN BERAT')) {
|
||||
latestData.status = 'SCANNING';
|
||||
} else if (message.includes('DHT ERROR')) {
|
||||
latestData.status = 'ERROR';
|
||||
}
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
await db.insertStatusHistory(message);
|
||||
console.log('✅ Status saved to database');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save status to database:', dbError.message);
|
||||
}
|
||||
console.log('📊 Status:', message);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// TOPIC: novil/pengering/button
|
||||
// Format: "BUTTON PRESSED", "START_BUTTON", "RESET_BUTTON"
|
||||
// =====================================================
|
||||
if (topic === 'novil/pengering/button') {
|
||||
console.log('🔘 Button Event:', message);
|
||||
|
||||
// Save button event to status history
|
||||
try {
|
||||
await db.insertStatusHistory(`BUTTON: ${message}`);
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save button event:', dbError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// TOPIC: novil/pengering/control
|
||||
// Format: "STATUS:HEATER_ON", "HEATER_ON", "FAN_OFF", dll
|
||||
// =====================================================
|
||||
if (topic === 'novil/pengering/control') {
|
||||
console.log('🎛️ Control Message:', message);
|
||||
|
||||
// Jika pesan dimulai dengan "STATUS:", ini adalah feedback dari ESP32
|
||||
if (message.startsWith('STATUS:')) {
|
||||
const statusMsg = message.replace('STATUS:', '');
|
||||
console.log('📊 Relay Status Update:', statusMsg);
|
||||
|
||||
// Update relay state di latestData
|
||||
if (statusMsg === 'HEATER_ON') latestData.relay1 = true;
|
||||
else if (statusMsg === 'HEATER_OFF') latestData.relay1 = false;
|
||||
else if (statusMsg === 'FAN_ON') latestData.relay2 = true;
|
||||
else if (statusMsg === 'FAN_OFF') latestData.relay2 = false;
|
||||
else if (statusMsg === 'EXHAUST_ON') latestData.relay3 = true;
|
||||
else if (statusMsg === 'EXHAUST_OFF') latestData.relay3 = false;
|
||||
|
||||
// Save status update to status_history
|
||||
try {
|
||||
await db.insertStatusHistory(`RELAY: ${statusMsg}`);
|
||||
console.log('✅ Relay status saved to database');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save relay status:', dbError.message);
|
||||
}
|
||||
} else {
|
||||
// Ini adalah command dari API/Flutter, simpan ke control_commands
|
||||
try {
|
||||
await db.insertControlCommand(message, 'MQTT');
|
||||
console.log('✅ Control command saved to database');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save control command:', dbError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
aedes.on('subscribe', (subscriptions, client) => {
|
||||
console.log(`📬 Client ${client.id} subscribed to:`, subscriptions.map(s => s.topic).join(', '));
|
||||
});
|
||||
|
||||
// Start MQTT Server (only if not in production or if explicitly enabled)
|
||||
if (process.env.ENABLE_MQTT_BROKER === 'true') {
|
||||
mqttServer.listen(MQTT_PORT, () => {
|
||||
console.log(`🚀 MQTT Broker running on port ${MQTT_PORT}`);
|
||||
}).on('error', (err) => {
|
||||
console.warn(`⚠️ MQTT Broker failed to start on port ${MQTT_PORT}:`, err.message);
|
||||
console.log('ℹ️ MQTT Broker disabled. Use external MQTT broker (e.g., HiveMQ)');
|
||||
});
|
||||
} else {
|
||||
console.log('ℹ️ MQTT Broker disabled (use external MQTT broker like HiveMQ)');
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// WEBSOCKET MQTT (for web clients)
|
||||
// =====================================================
|
||||
const httpServer = http.createServer();
|
||||
const ws = new WebSocketServer({ server: httpServer });
|
||||
|
||||
ws.on('connection', (socket) => {
|
||||
const stream = require('websocket-stream')(socket);
|
||||
aedes.handle(stream);
|
||||
console.log('🌐 WebSocket MQTT client connected');
|
||||
});
|
||||
|
||||
if (process.env.ENABLE_MQTT_BROKER === 'true') {
|
||||
httpServer.listen(WS_PORT, () => {
|
||||
console.log(`🌐 WebSocket MQTT running on port ${WS_PORT}`);
|
||||
}).on('error', (err) => {
|
||||
console.warn(`⚠️ WebSocket MQTT failed to start on port ${WS_PORT}:`, err.message);
|
||||
});
|
||||
} else {
|
||||
console.log('ℹ️ WebSocket MQTT disabled');
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// REST API ENDPOINTS
|
||||
// =====================================================
|
||||
|
||||
// Health check
|
||||
app.get('/', (req, res) => {
|
||||
res.json({
|
||||
status: 'OK',
|
||||
message: 'Pengering Ikan Backend Server - MQTT Client Enabled',
|
||||
version: '1.0.3-relay-fix', // Updated version with relay boolean fix
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize database tables (manual trigger)
|
||||
app.post('/api/database/init', async (req, res) => {
|
||||
try {
|
||||
const connected = await db.testConnection();
|
||||
if (!connected) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Database connection failed'
|
||||
});
|
||||
}
|
||||
|
||||
const initialized = await db.initDatabase();
|
||||
if (initialized) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Database tables initialized successfully'
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to initialize database tables'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error initializing database',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Test database connection
|
||||
app.get('/api/database/test', async (req, res) => {
|
||||
try {
|
||||
const connected = await db.testConnection();
|
||||
if (connected) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Database connection successful'
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Database connection failed'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error testing database connection',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Post sensor data from ESP32
|
||||
app.post('/api/data/sensor', async (req, res) => {
|
||||
try {
|
||||
const { suhu, berat, target, relay1, relay2, relay3, relay4, status } = req.body;
|
||||
|
||||
// Validate data
|
||||
if (suhu === undefined || berat === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Suhu and berat are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Update latest data
|
||||
latestData = {
|
||||
suhu: suhu || 0,
|
||||
berat: berat || 0,
|
||||
target: target || 0,
|
||||
relay1: relay1 || false,
|
||||
relay2: relay2 || false,
|
||||
relay3: relay3 || false,
|
||||
relay4: relay4 || false,
|
||||
status: status || 'UNKNOWN',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
await db.insertSensorData(latestData);
|
||||
console.log('✅ Data from ESP32 saved to database');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save to database:', dbError.message);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Data received successfully',
|
||||
data: latestData
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error processing sensor data',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Post status from ESP32
|
||||
app.post('/api/status', async (req, res) => {
|
||||
try {
|
||||
const { message } = req.body;
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Status message is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
await db.insertStatusHistory(message);
|
||||
console.log('✅ Status from ESP32 saved:', message);
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save status to database:', dbError.message);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Status received successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error processing status',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get latest data
|
||||
app.get('/api/data/latest', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: latestData
|
||||
});
|
||||
});
|
||||
|
||||
// Get data history
|
||||
app.get('/api/data/history', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const history = await db.getSensorDataHistory(limit);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: history.length,
|
||||
data: history
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get data history',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get status history
|
||||
app.get('/api/status/history', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const history = await db.getStatusHistory(limit);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: history.length,
|
||||
data: history
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get status history',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Publish control command
|
||||
app.post('/api/control', async (req, res) => {
|
||||
const { command } = req.body;
|
||||
|
||||
if (!command) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Command is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Valid commands (sesuai dengan ESP32)
|
||||
const validCommands = [
|
||||
'HEATER_ON', 'HEATER_OFF',
|
||||
'FAN_ON', 'FAN_OFF',
|
||||
'LAMP_ON', 'LAMP_OFF',
|
||||
'EXHAUST_ON', 'EXHAUST_OFF',
|
||||
'START', // Mulai pengeringan
|
||||
'RESET' // Reset ke mode ready
|
||||
];
|
||||
|
||||
if (!validCommands.includes(command)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid command',
|
||||
validCommands: validCommands
|
||||
});
|
||||
}
|
||||
|
||||
// Save command to database
|
||||
try {
|
||||
await db.insertControlCommand(command, 'API');
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save command to database:', dbError.message);
|
||||
}
|
||||
|
||||
// Publish to MQTT (HiveMQ)
|
||||
mqttClient.publish(MQTT_TOPICS.control, command, { qos: 1 }, (err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to publish command',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ Command published to MQTT: ${command}`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Command sent successfully',
|
||||
command: command
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Get statistics
|
||||
app.get('/api/stats', async (req, res) => {
|
||||
try {
|
||||
const dbStats = await db.getStatistics();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats: {
|
||||
mqttConnected: mqttClient.connected,
|
||||
...dbStats,
|
||||
latestData: latestData,
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
success: true,
|
||||
stats: {
|
||||
mqttConnected: mqttClient.connected,
|
||||
latestData: latestData,
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
databaseError: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Clear history
|
||||
app.delete('/api/history/clear', async (req, res) => {
|
||||
try {
|
||||
await db.clearOldData(0); // Clear all data
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'History cleared successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to clear history',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Endpoint not found'
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('❌ Error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
error: err.message
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// START EXPRESS SERVER
|
||||
// =====================================================
|
||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log('🚀 PENGERING IKAN BACKEND SERVER v1.0.3-relay-fix');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`📡 REST API: http://0.0.0.0:${PORT}`);
|
||||
console.log(`🔗 MQTT Broker: ${MQTT_BROKER_URL}`);
|
||||
console.log(`📊 MQTT Status: ${mqttClient.connected ? '✅ Connected' : '⏳ Connecting...'}`);
|
||||
console.log('');
|
||||
console.log('📋 Available Endpoints:');
|
||||
console.log(' GET / - Health check');
|
||||
console.log(' GET /api/data/latest - Get latest sensor data');
|
||||
console.log(' GET /api/data/history - Get data history');
|
||||
console.log(' GET /api/status/history - Get status history');
|
||||
console.log(' GET /api/stats - Get server statistics');
|
||||
console.log(' POST /api/control - Send control command');
|
||||
console.log(' POST /api/data/sensor - Post sensor data (ESP32)');
|
||||
console.log(' POST /api/status - Post status (ESP32)');
|
||||
console.log(' DELETE /api/history/clear - Clear history');
|
||||
console.log('');
|
||||
console.log('📡 MQTT Topics:');
|
||||
console.log(' Subscribe:', MQTT_TOPICS.data);
|
||||
console.log(' Subscribe:', MQTT_TOPICS.status);
|
||||
console.log(' Subscribe:', MQTT_TOPICS.button);
|
||||
console.log(' Publish: ', MQTT_TOPICS.control);
|
||||
console.log('');
|
||||
console.log('✅ Server is ready!');
|
||||
console.log('='.repeat(60));
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
console.error('❌ Server error:', error);
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.error(`Port ${PORT} is already in use`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// GRACEFUL SHUTDOWN
|
||||
// =====================================================
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('🛑 SIGTERM received, shutting down gracefully...');
|
||||
|
||||
// Close MQTT client
|
||||
if (mqttClient) {
|
||||
mqttClient.end(() => {
|
||||
console.log('✅ MQTT client closed');
|
||||
});
|
||||
}
|
||||
|
||||
// Close HTTP server
|
||||
server.close(() => {
|
||||
console.log('✅ HTTP server closed');
|
||||
});
|
||||
|
||||
// Close MQTT broker if enabled
|
||||
if (process.env.ENABLE_MQTT_BROKER === 'true') {
|
||||
mqttServer.close(() => {
|
||||
console.log('✅ MQTT server closed');
|
||||
});
|
||||
httpServer.close(() => {
|
||||
console.log('✅ WebSocket server closed');
|
||||
});
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('🛑 SIGINT received, shutting down gracefully...');
|
||||
|
||||
// Close MQTT client
|
||||
if (mqttClient) {
|
||||
mqttClient.end(() => {
|
||||
console.log('✅ MQTT client closed');
|
||||
});
|
||||
}
|
||||
|
||||
// Close HTTP server
|
||||
server.close(() => {
|
||||
console.log('✅ HTTP server closed');
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('❌ Uncaught Exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
### Backend API Testing
|
||||
### Gunakan REST Client extension di VS Code
|
||||
|
||||
@baseUrl = https://web-production-47eb.up.railway.app
|
||||
# @baseUrl = http://localhost:3000
|
||||
|
||||
### 1. Health Check
|
||||
GET {{baseUrl}}/
|
||||
|
||||
### 2. Get Latest Data
|
||||
GET {{baseUrl}}/api/data/latest
|
||||
|
||||
### 3. Get Data History (default 50)
|
||||
GET {{baseUrl}}/api/data/history
|
||||
|
||||
### 4. Get Data History (limit 10)
|
||||
GET {{baseUrl}}/api/data/history?limit=10
|
||||
|
||||
### 5. Get Status History
|
||||
GET {{baseUrl}}/api/status/history
|
||||
|
||||
### 6. Get Server Statistics
|
||||
GET {{baseUrl}}/api/stats
|
||||
|
||||
### 7. Test Database Connection
|
||||
GET {{baseUrl}}/api/database/test
|
||||
|
||||
### 8. Initialize Database (run once)
|
||||
POST {{baseUrl}}/api/database/init
|
||||
|
||||
### ========================================
|
||||
### CONTROL COMMANDS - SYSTEM
|
||||
### ========================================
|
||||
|
||||
### 9. START Pengeringan
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "START"
|
||||
}
|
||||
|
||||
### 10. RESET ke Ready Mode
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "RESET"
|
||||
}
|
||||
|
||||
### ========================================
|
||||
### CONTROL COMMANDS - RELAY
|
||||
### ========================================
|
||||
|
||||
### 11. HEATER ON
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "HEATER_ON"
|
||||
}
|
||||
|
||||
### 12. HEATER OFF
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "HEATER_OFF"
|
||||
}
|
||||
|
||||
### 13. FAN ON
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "FAN_ON"
|
||||
}
|
||||
|
||||
### 14. FAN OFF
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "FAN_OFF"
|
||||
}
|
||||
|
||||
### 15. LAMP ON
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "LAMP_ON"
|
||||
}
|
||||
|
||||
### 16. LAMP OFF
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "LAMP_OFF"
|
||||
}
|
||||
|
||||
### 17. EXHAUST ON
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "EXHAUST_ON"
|
||||
}
|
||||
|
||||
### 18. EXHAUST OFF
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "EXHAUST_OFF"
|
||||
}
|
||||
|
||||
### ========================================
|
||||
### MAINTENANCE
|
||||
### ========================================
|
||||
|
||||
### 19. Clear History
|
||||
DELETE {{baseUrl}}/api/history/clear
|
||||
|
||||
### ========================================
|
||||
### ERROR TESTING
|
||||
### ========================================
|
||||
|
||||
### 20. Invalid Command (should fail)
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "INVALID_COMMAND"
|
||||
}
|
||||
|
||||
### 21. Missing Command (should fail)
|
||||
POST {{baseUrl}}/api/control
|
||||
Content-Type: application/json
|
||||
|
||||
{}
|
||||
|
||||
### 22. 404 Not Found
|
||||
GET {{baseUrl}}/api/invalid-endpoint
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Android Adaptive Icon Generator</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #1976D2;
|
||||
text-align: center;
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.icon-preview {
|
||||
text-align: center;
|
||||
}
|
||||
canvas {
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 10px;
|
||||
margin: 10px;
|
||||
}
|
||||
input[type="file"] {
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
padding: 10px;
|
||||
}
|
||||
button {
|
||||
background: #1976D2;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
}
|
||||
button:hover {
|
||||
background: #1565C0;
|
||||
}
|
||||
.info {
|
||||
background: #E3F2FD;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.safe-zone {
|
||||
position: absolute;
|
||||
border: 3px dashed #FF5722;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎨 Android Adaptive Icon Generator</h1>
|
||||
|
||||
<div class="info">
|
||||
<strong>📱 Android Adaptive Icon Safe Zone:</strong><br>
|
||||
Your icon will be displayed in different shapes (circle, square, squircle).<br>
|
||||
Important content must stay within the <span style="color: #FF5722;">red circle</span> (66% area).
|
||||
</div>
|
||||
|
||||
<input type="file" id="imageInput" accept="image/*">
|
||||
|
||||
<div class="preview">
|
||||
<div class="icon-preview">
|
||||
<h3>Original</h3>
|
||||
<canvas id="originalCanvas" width="300" height="300"></canvas>
|
||||
</div>
|
||||
<div class="icon-preview">
|
||||
<h3>Adaptive Icon (66% size)</h3>
|
||||
<div style="position: relative; display: inline-block;">
|
||||
<canvas id="adaptiveCanvas" width="300" height="300"></canvas>
|
||||
<div class="safe-zone" style="width: 198px; height: 198px; top: 51px; left: 51px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="downloadIcon()">⬇️ Download Fixed Icon (1024x1024)</button>
|
||||
|
||||
<div class="info">
|
||||
<strong>Next steps after download:</strong><br>
|
||||
1. Replace <code>assets/images/app_icon.png</code> with downloaded file<br>
|
||||
2. Run: <code>flutter pub run flutter_launcher_icons</code><br>
|
||||
3. Build: <code>flutter build apk --release</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const originalCanvas = document.getElementById('originalCanvas');
|
||||
const adaptiveCanvas = document.getElementById('adaptiveCanvas');
|
||||
const originalCtx = originalCanvas.getContext('2d');
|
||||
const adaptiveCtx = adaptiveCanvas.getContext('2d');
|
||||
let currentImage = null;
|
||||
|
||||
document.getElementById('imageInput').addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(event) {
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
currentImage = img;
|
||||
drawIcons();
|
||||
};
|
||||
img.src = event.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
function drawIcons() {
|
||||
if (!currentImage) return;
|
||||
|
||||
// Clear canvases
|
||||
originalCtx.clearRect(0, 0, 300, 300);
|
||||
adaptiveCtx.clearRect(0, 0, 300, 300);
|
||||
|
||||
// Draw original
|
||||
originalCtx.drawImage(currentImage, 0, 0, 300, 300);
|
||||
|
||||
// Draw adaptive with background
|
||||
adaptiveCtx.fillStyle = '#1976D2';
|
||||
adaptiveCtx.fillRect(0, 0, 300, 300);
|
||||
|
||||
// Draw icon at 66% size (centered)
|
||||
const scaledSize = 300 * 0.66;
|
||||
const offset = (300 - scaledSize) / 2;
|
||||
adaptiveCtx.drawImage(currentImage, offset, offset, scaledSize, scaledSize);
|
||||
}
|
||||
|
||||
function downloadIcon() {
|
||||
if (!currentImage) {
|
||||
alert('Please load an image first!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create high-res canvas for export (1024x1024)
|
||||
const exportCanvas = document.createElement('canvas');
|
||||
exportCanvas.width = 1024;
|
||||
exportCanvas.height = 1024;
|
||||
const ctx = exportCanvas.getContext('2d');
|
||||
|
||||
// Fill with transparent or background
|
||||
ctx.fillStyle = 'transparent';
|
||||
ctx.fillRect(0, 0, 1024, 1024);
|
||||
|
||||
// Draw icon at 66% size (safe zone)
|
||||
const scaledSize = 1024 * 0.66;
|
||||
const offset = (1024 - scaledSize) / 2;
|
||||
ctx.drawImage(currentImage, offset, offset, scaledSize, scaledSize);
|
||||
|
||||
// Download
|
||||
exportCanvas.toBlob(function(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'app_icon_fixed.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fix App Icon - Add padding to prevent cropping on Android
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
import os
|
||||
|
||||
def create_icon_with_padding(input_path, output_path, padding_percent=15):
|
||||
"""
|
||||
Create icon with padding to fit Android adaptive icon safe zone
|
||||
|
||||
Android adaptive icons use a circular mask, so we need to ensure
|
||||
the important content fits within a circle of ~80% of the icon size
|
||||
"""
|
||||
print(f"📷 Loading icon: {input_path}")
|
||||
|
||||
# Open original image
|
||||
img = Image.open(input_path).convert('RGBA')
|
||||
original_size = img.size[0]
|
||||
|
||||
print(f" Original size: {original_size}x{original_size}")
|
||||
|
||||
# Calculate new size with padding
|
||||
padding = int(original_size * (padding_percent / 100))
|
||||
new_size = original_size + (padding * 2)
|
||||
|
||||
print(f" Adding padding: {padding}px on each side")
|
||||
print(f" New size: {new_size}x{new_size}")
|
||||
|
||||
# Create new image with transparent background
|
||||
new_img = Image.new('RGBA', (new_size, new_size), (0, 0, 0, 0))
|
||||
|
||||
# Paste original image in center
|
||||
new_img.paste(img, (padding, padding), img)
|
||||
|
||||
# Save result
|
||||
new_img.save(output_path, 'PNG')
|
||||
print(f"✅ Icon saved: {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
def create_icon_with_background(input_path, output_path, bg_color='#1976D2', padding_percent=15):
|
||||
"""
|
||||
Create icon with colored background (better for adaptive icons)
|
||||
"""
|
||||
print(f"📷 Loading icon: {input_path}")
|
||||
|
||||
# Open original image
|
||||
img = Image.open(input_path).convert('RGBA')
|
||||
original_size = img.size[0]
|
||||
|
||||
print(f" Original size: {original_size}x{original_size}")
|
||||
|
||||
# Calculate new size with padding
|
||||
padding = int(original_size * (padding_percent / 100))
|
||||
new_size = original_size + (padding * 2)
|
||||
|
||||
print(f" Adding padding: {padding}px on each side")
|
||||
print(f" New size: {new_size}x{new_size}")
|
||||
print(f" Background color: {bg_color}")
|
||||
|
||||
# Create new image with colored background
|
||||
new_img = Image.new('RGBA', (new_size, new_size), bg_color)
|
||||
|
||||
# Paste original image in center
|
||||
new_img.paste(img, (padding, padding), img)
|
||||
|
||||
# Save result
|
||||
new_img.save(output_path, 'PNG')
|
||||
print(f"✅ Icon saved: {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
|
||||
print("🔧 Android Adaptive Icon Fixer")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
# Paths
|
||||
input_icon = 'assets/images/app_icon.png'
|
||||
|
||||
if not os.path.exists(input_icon):
|
||||
print(f"❌ Error: Icon not found at {input_icon}")
|
||||
sys.exit(1)
|
||||
|
||||
# Backup original
|
||||
backup_path = 'assets/images/app_icon_backup.png'
|
||||
if not os.path.exists(backup_path):
|
||||
Image.open(input_icon).save(backup_path)
|
||||
print(f"💾 Backup saved: {backup_path}")
|
||||
print()
|
||||
|
||||
print("Choose icon style:")
|
||||
print("1. Transparent background with padding (default)")
|
||||
print("2. Blue background with padding (recommended for Android)")
|
||||
print()
|
||||
|
||||
choice = input("Enter choice (1 or 2, default=2): ").strip() or "2"
|
||||
|
||||
print()
|
||||
|
||||
if choice == "1":
|
||||
# Transparent with padding
|
||||
create_icon_with_padding(
|
||||
input_icon,
|
||||
input_icon,
|
||||
padding_percent=20
|
||||
)
|
||||
else:
|
||||
# Blue background with padding
|
||||
create_icon_with_background(
|
||||
input_icon,
|
||||
input_icon,
|
||||
bg_color='#1976D2', # Material Blue
|
||||
padding_percent=15
|
||||
)
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("✅ Icon fixed successfully!")
|
||||
print()
|
||||
print("Next steps:")
|
||||
print("1. Run: flutter pub run flutter_launcher_icons")
|
||||
print("2. Build APK: flutter build apk --release")
|
||||
print("3. Check the new icon on your device")
|
||||
print()
|
||||
print("If icon still looks wrong, try running this script again")
|
||||
print("and choose the other option.")
|
||||
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
|
@ -0,0 +1 @@
|
|||
r
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
"""
|
||||
Script untuk generate icon dan splash screen
|
||||
Sesuai dengan desain loading screen aplikasi Pengering Ikan
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
# Buat folder assets jika belum ada
|
||||
os.makedirs("assets/images", exist_ok=True)
|
||||
|
||||
# ========================================
|
||||
# 1. GENERATE APP ICON (1024x1024)
|
||||
# ========================================
|
||||
|
||||
print("🎨 Generating app icon...")
|
||||
|
||||
# Buat canvas
|
||||
icon_size = 1024
|
||||
icon = Image.new('RGB', (icon_size, icon_size), color='white')
|
||||
draw = ImageDraw.Draw(icon)
|
||||
|
||||
# Background gradient biru (simplified - solid blue)
|
||||
# Untuk gradient yang lebih bagus, butuh library tambahan
|
||||
# Kita pakai solid blue yang matching
|
||||
blue_color = (25, 118, 210) # #1976D2
|
||||
draw.rectangle([0, 0, icon_size, icon_size], fill=blue_color)
|
||||
|
||||
# Gambar lingkaran putih di tengah
|
||||
circle_radius = 350
|
||||
circle_center = (icon_size // 2, icon_size // 2)
|
||||
draw.ellipse(
|
||||
[
|
||||
circle_center[0] - circle_radius,
|
||||
circle_center[1] - circle_radius,
|
||||
circle_center[0] + circle_radius,
|
||||
circle_center[1] + circle_radius
|
||||
],
|
||||
fill='white'
|
||||
)
|
||||
|
||||
# Gambar icon ikan (simplified - gunakan emoji atau text)
|
||||
# Karena kita tidak bisa render Material Icons, kita gunakan emoji ikan
|
||||
try:
|
||||
# Coba load font yang support emoji
|
||||
font_size = 400
|
||||
# Windows default font
|
||||
font = ImageFont.truetype("seguiemj.ttf", font_size)
|
||||
except:
|
||||
# Fallback ke default font
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Gambar emoji ikan di tengah
|
||||
fish_emoji = "🐟"
|
||||
# Get text bounding box
|
||||
bbox = draw.textbbox((0, 0), fish_emoji, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
text_x = (icon_size - text_width) // 2
|
||||
text_y = (icon_size - text_height) // 2 - 50
|
||||
|
||||
draw.text((text_x, text_y), fish_emoji, fill=blue_color, font=font)
|
||||
|
||||
# Save icon
|
||||
icon.save("assets/images/app_icon.png")
|
||||
print("✅ App icon saved: assets/images/app_icon.png")
|
||||
|
||||
# ========================================
|
||||
# 2. GENERATE SPLASH SCREEN (1080x1920)
|
||||
# ========================================
|
||||
|
||||
print("🖼️ Generating splash screen...")
|
||||
|
||||
# Buat canvas
|
||||
splash_width = 1080
|
||||
splash_height = 1920
|
||||
splash = Image.new('RGB', (splash_width, splash_height), color='white')
|
||||
draw = ImageDraw.Draw(splash)
|
||||
|
||||
# Background gradient biru (simplified - solid blue)
|
||||
draw.rectangle([0, 0, splash_width, splash_height], fill=blue_color)
|
||||
|
||||
# Gambar lingkaran putih di tengah (lebih kecil untuk splash)
|
||||
circle_radius_splash = 200
|
||||
circle_center_splash = (splash_width // 2, splash_height // 2 - 200)
|
||||
draw.ellipse(
|
||||
[
|
||||
circle_center_splash[0] - circle_radius_splash,
|
||||
circle_center_splash[1] - circle_radius_splash,
|
||||
circle_center_splash[0] + circle_radius_splash,
|
||||
circle_center_splash[1] + circle_radius_splash
|
||||
],
|
||||
fill='white'
|
||||
)
|
||||
|
||||
# Gambar icon ikan
|
||||
try:
|
||||
font_splash = ImageFont.truetype("seguiemj.ttf", 220)
|
||||
except:
|
||||
font_splash = ImageFont.load_default()
|
||||
|
||||
bbox = draw.textbbox((0, 0), fish_emoji, font=font_splash)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
text_x = (splash_width - text_width) // 2
|
||||
text_y = circle_center_splash[1] - text_height // 2 - 30
|
||||
|
||||
draw.text((text_x, text_y), fish_emoji, fill=blue_color, font=font_splash)
|
||||
|
||||
# Tambah text "Pengering Ikan"
|
||||
try:
|
||||
title_font = ImageFont.truetype("arialbd.ttf", 80)
|
||||
except:
|
||||
title_font = ImageFont.load_default()
|
||||
|
||||
title_text = "Pengering Ikan"
|
||||
bbox = draw.textbbox((0, 0), title_text, font=title_font)
|
||||
title_width = bbox[2] - bbox[0]
|
||||
title_x = (splash_width - title_width) // 2
|
||||
title_y = circle_center_splash[1] + circle_radius_splash + 100
|
||||
|
||||
draw.text((title_x, title_y), title_text, fill='white', font=title_font)
|
||||
|
||||
# Tambah subtitle
|
||||
try:
|
||||
subtitle_font = ImageFont.truetype("arial.ttf", 40)
|
||||
except:
|
||||
subtitle_font = ImageFont.load_default()
|
||||
|
||||
subtitle_text = "Monitoring & Control System"
|
||||
bbox = draw.textbbox((0, 0), subtitle_text, font=subtitle_font)
|
||||
subtitle_width = bbox[2] - bbox[0]
|
||||
subtitle_x = (splash_width - subtitle_width) // 2
|
||||
subtitle_y = title_y + 100
|
||||
|
||||
# Warna putih dengan opacity (simplified - pakai putih biasa)
|
||||
draw.text((subtitle_x, subtitle_y), subtitle_text, fill=(255, 255, 255, 180), font=subtitle_font)
|
||||
|
||||
# Save splash
|
||||
splash.save("assets/images/splash.png")
|
||||
print("✅ Splash screen saved: assets/images/splash.png")
|
||||
|
||||
print("")
|
||||
print("========================================")
|
||||
print("✅ DONE!")
|
||||
print("========================================")
|
||||
print("")
|
||||
print("📊 Generated files:")
|
||||
print(" - assets/images/app_icon.png (1024x1024)")
|
||||
print(" - assets/images/splash.png (1080x1920)")
|
||||
print("")
|
||||
print("🚀 Next steps:")
|
||||
print(" 1. Run: .\\setup-icon-splash.ps1")
|
||||
print(" 2. Run: .\\build-apk.ps1")
|
||||
print("")
|
||||
|
|
@ -431,7 +431,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
@ -488,7 +488,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 392 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 763 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 532 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 763 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 929 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 3.3 KiB |
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "background.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 69 B |