Compare commits

...

10 Commits

Author SHA1 Message Date
afanfahreza 6fc5d105a5 first commit 2026-08-05 15:31:12 +07:00
flems 18114de707 Cleanup: Hapus folder Flutter yang tidak diperlukan di backend 2026-06-25 18:32:41 +07:00
flems 349a275594 Fix notifikasi dan tambah auto-disconnect detection
- Backend: mapping status ESP32 (SELESAI -> COMPLETED)
- Backend: auto-detect disconnect setelah 30 detik no data
- ESP32: kirim status relay setelah pengeringan selesai
- Flutter: tambah debug log dan UI untuk status DISCONNECTED
- Flutter: perbaiki overflow di device card
- Notifikasi pengeringan selesai sudah berfungsi dengan baik
2026-06-25 18:28:52 +07:00
flems ef9a79df9e Fix: Convert boolean relay values to integer for MySQL database
- Fixed relay status saving issue where all relays showed 0 in database
- Added explicit boolean to integer conversion (true->1, false->0)
- Applied to relay1, relay2, relay3, relay4 columns
- MySQL BOOLEAN columns are stored as TINYINT(1) requiring numeric values
2026-06-25 09:38:26 +07:00
flems bee56e2cd0 Release v1.1.0: Real-time Relay Status & Smart Exhaust Control
 Fitur Baru:
- Status relay real-time di aplikasi (Heater 1, Heater 2, Fan, Exhaust)
- Exhaust otomatis ON saat suhu >= 60°C
- Cooling down 20 detik dengan countdown setelah pengeringan selesai
- Auto reset ke Ready to Dry setelah cooling down

🔧 Backend:
- Parse relay1-4 dari JSON MQTT ESP32
- Real-time update status relay di database dan API

🔧 ESP32:
- Kirim status semua relay dalam JSON ke MQTT
- Support 4 relay: Heater1 (GPIO13), Heater2 (GPIO12), Fan (GPIO14), Exhaust (GPIO27)
- Kontrol exhaust otomatis berdasarkan suhu
- Error handling load cell dengan timeout

🐛 Bug Fixes:
- Fix perhitungan berat load cell (dari rata-rata ke penjumlahan)
- Fix crash saat inisialisasi HX711
- Fix status relay tidak update di Flutter

📦 Version: 1.1.0 (Build 2)
2026-06-24 02:29:22 +07:00
flems 63ffe9c8ed Fix: Save relay status dari ESP32 ke control_commands table 2026-05-29 05:44:23 +07:00
flems ce1b178ae5 Fix: Subscribe to control topic and save to database 2026-05-29 05:32:58 +07:00
flems 59d89ae7a5 Fix: Convert timestamp to WIB (UTC+7) timezone 2026-05-29 04:26:44 +07:00
flems 5dd7cb1e1a Trigger redeploy: Update version to 1.0.1 2026-05-29 03:59:46 +07:00
flems 54ab217d2d Fix: Backend connect to HiveMQ as MQTT client untuk save data ke MySQL 2026-05-29 03:53:44 +07:00
134 changed files with 9971 additions and 866 deletions

12
.flutter-plugins Normal file
View File

@ -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\\

2
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,2 @@
{
}

127
CHANGELOG_NOTIFIKASI.md Normal file
View File

@ -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

244
GRAFIK_CHART_GUIDE.md Normal file
View File

@ -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! 🎉**

161
NOTIFICATION_FIX_README.md Normal file
View File

@ -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
View File

@ -1,17 +1,244 @@
# aplikasi_novil
# 📱 Aplikasi Monitoring Pengering Ikan
A new Flutter project.
Aplikasi mobile untuk monitoring sistem pengering ikan otomatis berbasis IoT dengan ESP32.
## Getting Started
---
This project is a starting point for a Flutter application.
## 🎯 Fitur Utama
A few resources to get you started if this is your first Flutter project:
- ✅ **Monitoring Real-time** - Data update setiap 5 detik
- ✅ **Push Notifications** - Notifikasi status pengeringan
- ✅ **Dashboard Lengkap** - Suhu, berat, target, status device
- ✅ **Cloud Backend** - Data tersimpan di Railway MySQL
- ✅ **Display Only** - Monitoring tanpa kontrol manual
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
---
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
## 🏗️ Arsitektur Sistem
```
ESP32 (Hardware)
↓ MQTT Publish
Backend Railway (Node.js)
↓ Save to Database
MySQL Database
↑ API Polling (5 detik)
Aplikasi Flutter (Android)
```
---
## 📦 Komponen
### 1. **Hardware (ESP32)**
- File: `esp32_pengering_ikan_railway.ino`
- Sensor: DHT22 (suhu), HX711 (berat)
- Relay: 4 channel (Heater, Fan, Lamp, Exhaust)
- Koneksi: WiFi + MQTT
### 2. **Backend (Node.js)**
- Folder: `backend/`
- Framework: Express.js
- Database: MySQL (Railway)
- MQTT Client: Subscribe dari ESP32
- REST API: Endpoint untuk aplikasi
### 3. **Mobile App (Flutter)**
- Folder: `lib/`
- Platform: Android
- Update: API polling 5 detik
- Notifikasi: flutter_local_notifications
---
## 🚀 Quick Start
### Build APK:
```bash
# Jalankan script build
.\build-with-icon.ps1
# Atau manual
flutter build apk --release
```
### Install di HP:
1. Copy `PengeringIkan.apk` dari Desktop
2. Install di HP Android
3. Izinkan notifikasi
4. Buka aplikasi
---
## 📱 Tampilan Aplikasi
### Status Card
- Status sistem (READY, SCANNING, BERJALAN, SELESAI)
- Waktu mulai & durasi
- Pesan status
### Metrics
- 🌡️ Suhu (°C)
- ⚖️ Berat (gram)
- 🎯 Target berat (gram)
### Device Status
- 🔥 Heater (ON/OFF)
- 💨 Fan (ON/OFF)
- 🌪️ Exhaust (ON/OFF)
### Connection Indicator
- ☁️🟢 API Connected
- ☁️🔴 API Disconnected
---
## 🔔 Notifikasi
Aplikasi mengirim push notification saat:
1. **Pengeringan Siap** (Status → READY)
2. **Ikan Terdeteksi** (Status → BERJALAN)
3. **Pengeringan Selesai** (Status → SELESAI)
Notifikasi muncul di dalam & luar aplikasi.
---
## 🔧 Konfigurasi
### Backend URL:
```dart
// lib/services/api_service.dart
static const String baseUrl = 'https://web-production-47eb.up.railway.app';
```
### Polling Interval:
```dart
// lib/screens/dashboard_screen_with_api.dart
Timer.periodic(const Duration(seconds: 5), ...);
```
### MQTT Topics (Backend):
```javascript
// backend/server.js
const TOPIC_DATA = 'novil/pengering/data';
const TOPIC_STATUS = 'novil/pengering/status';
const TOPIC_CONTROL = 'novil/pengering/control';
```
---
## 📊 API Endpoints
Dokumentasi lengkap: `backend/API_DOCUMENTATION.md`
### Main Endpoints:
- `GET /` - Health check
- `GET /api/data/latest` - Data sensor terbaru
- `GET /api/data/history` - Riwayat data
- `GET /api/status/history` - Riwayat status
- `GET /api/stats` - Statistik
---
## 🛠️ Development
### Requirements:
- Flutter SDK 3.5+
- Android SDK
- Node.js 18+
- MySQL 8.0+
### Setup Backend:
```bash
cd backend
npm install
npm start
```
### Setup Flutter:
```bash
flutter pub get
flutter run
```
---
## 📄 Dokumentasi
- **APK_MONITORING_FINAL.md** - Panduan lengkap aplikasi
- **PERBAIKAN_APK_FINAL.md** - Changelog & perbaikan
- **PERBAIKAN_STATUS_MQTT.md** - Penjelasan API only
- **backend/API_DOCUMENTATION.md** - API reference
---
## 🔍 Troubleshooting
### Icon Cloud Merah?
- Cek koneksi internet HP
- Test backend: https://web-production-47eb.up.railway.app/
- Restart aplikasi
### Data Tidak Update?
- Pastikan icon cloud hijau
- Cek ESP32 status (WiFi connected)
- Cek backend logs di Railway
### Notifikasi Tidak Muncul?
- Izinkan notifikasi di Settings
- Disable battery optimization
- Cek status berubah di ESP32
---
## 📝 Version History
### v1.0.0 (1 Juni 2026) - Current
- ✅ Monitoring only (no control)
- ✅ API polling 5 detik
- ✅ Push notifications
- ✅ 3 device cards (Heater, Fan, Exhaust)
- ✅ Auto-update data
- ✅ Connection indicator
---
## 👨‍💻 Tech Stack
- **Mobile:** Flutter 3.5, Dart 3.0
- **Backend:** Node.js 18, Express.js
- **Database:** MySQL 8.0 (Railway)
- **IoT:** ESP32, Arduino IDE
- **Protocol:** MQTT, REST API
- **Hosting:** Railway.app
---
## 📞 Support
Untuk pertanyaan atau masalah:
1. Cek dokumentasi di folder `docs/`
2. Lihat troubleshooting di README
3. Cek logs di Railway dashboard
---
## 📜 License
Private project - All rights reserved
---
**Build Date:** 1 Juni 2026
**Version:** 1.0.0
**Status:** ✅ Production Ready
🎉 **Aplikasi siap digunakan untuk monitoring pengering ikan!**

View File

@ -8,15 +8,19 @@ plugins {
android {
namespace = "com.example.aplikasi_novil"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
// Suppress obsolete Java version warnings
freeCompilerArgs += listOf("-Xlint:-options")
}
defaultConfig {
@ -35,6 +39,17 @@ android {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// Disable minify and shrink to prevent notification issues
// Set to true if you want smaller APK size, but make sure ProGuard rules are correct
isMinifyEnabled = false
isShrinkResources = false
// ProGuard rules (uncomment if you enable minify)
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
@ -42,3 +57,7 @@ android {
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}

69
android/app/proguard-rules.pro vendored Normal file
View File

@ -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>(...);
}

View File

@ -1,8 +1,24 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permission untuk internet (MQTT) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Permission untuk notifikasi Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Permission untuk vibration notifikasi -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Permission untuk receive boot completed (agar app bisa restart service) -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- Permission untuk wake lock (agar notifikasi tetap jalan di background) -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="aplikasi_novil"
android:label="Pengering Ikan"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"
android:exported="true"
@ -11,7 +27,9 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
android:showWhenLocked="true"
android:turnScreenOn="true">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
@ -25,6 +43,22 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Receiver untuk boot completed -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
</intent-filter>
</receiver>
<!-- Receiver untuk scheduled notifications -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
android:exported="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@ -1,5 +1,32 @@
package com.example.aplikasi_novil
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Create notification channel for Android 8.0+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Channel untuk notifikasi pengeringan selesai
val channel = NotificationChannel(
"pengering_ikan_channel",
"Notifikasi Pengering Ikan",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifikasi untuk status pengeringan ikan"
enableVibration(true)
enableLights(true)
}
notificationManager.createNotificationChannel(channel)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,12 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<!-- Simple launch background - no splash image -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item android:drawable="@android:color/white" />
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,12 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<!-- Simple launch background - no splash image -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -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>

View File

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2196F3</color>
</resources>

View File

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@ -14,6 +14,18 @@ rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
// Force all subprojects to use Java 17
afterEvaluate {
if (project.hasProperty("android")) {
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
}
}
}
subprojects {
project.evaluationDependsOn(":app")

View File

@ -1,2 +1,16 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# Suppress obsolete Java warnings
android.suppressUnsupportedCompileSdk=34
org.gradle.warning.mode=none
# Force Java 17 for all modules
# org.gradle.java.home=
kotlin.jvm.target.validation.mode=warning
# Enable R8 full mode (optional, for better optimization)
android.enableR8.fullMode=false
# Suppress Kotlin compiler warnings
kotlin.compiler.suppressWarnings=false

BIN
assets/images/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

37
backend/.env.example Normal file
View File

@ -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)
# =====================================================

29
backend/.gitignore vendored Normal file
View File

@ -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/

View File

@ -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! ✅

112
backend/CARA_PUSH.md Normal file
View File

@ -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
```

1
backend/Procfile Normal file
View File

@ -0,0 +1 @@
web: node server.js

17
backend/README.md Normal file
View File

@ -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.

View File

@ -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! 🚀🎉

44
backend/create_tables.sql Normal file
View File

@ -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;

339
backend/database.js Normal file
View File

@ -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
};

111
backend/migrate_database.js Normal file
View File

@ -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);
});

35
backend/package.json Normal file
View File

@ -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"
}
}

42
backend/push.ps1 Normal file
View File

@ -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
}

9
backend/railway.toml Normal file
View File

@ -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

140
backend/schema.sql Normal file
View File

@ -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

961
backend/server.js Normal file
View File

@ -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);
});

145
backend/test-api.http Normal file
View File

@ -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

185
create_adaptive_icon.html Normal file
View File

@ -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>

3
devtools_options.yaml Normal file
View File

@ -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:

File diff suppressed because it is too large Load Diff

131
fix_icon.py Normal file
View File

@ -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.")

BIN
flutter_01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

BIN
flutter_02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

1
flutter_command.txt Normal file
View File

@ -0,0 +1 @@
r

155
generate_icon.py Normal file
View File

@ -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("")

View File

@ -431,7 +431,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@ -488,7 +488,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Some files were not shown because too many files have changed in this diff Show More