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
|
## 🏗️ Arsitektur Sistem
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
|
||||||
samples, guidance on mobile development, and a full API reference.
|
```
|
||||||
|
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 {
|
android {
|
||||||
namespace = "com.example.aplikasi_novil"
|
namespace = "com.example.aplikasi_novil"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = "27.0.12077973"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
|
|
||||||
|
// Suppress obsolete Java version warnings
|
||||||
|
freeCompilerArgs += listOf("-Xlint:-options")
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
|
@ -35,6 +39,17 @@ android {
|
||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
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 {
|
flutter {
|
||||||
source = "../.."
|
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">
|
<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
|
<application
|
||||||
android:label="aplikasi_novil"
|
android:label="Pengering Ikan"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/launcher_icon">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
@ -11,7 +27,9 @@
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
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
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
|
@ -25,6 +43,22 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,32 @@
|
||||||
package com.example.aplikasi_novil
|
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
|
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"?>
|
<?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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<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>
|
</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"?>
|
<?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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<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>
|
</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
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<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>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
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
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<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>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,18 @@ rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
subprojects {
|
subprojects {
|
||||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
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 {
|
subprojects {
|
||||||
project.evaluationDependsOn(":app")
|
project.evaluationDependsOn(":app")
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,16 @@
|
||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
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;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|
@ -488,7 +488,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
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 |