From 6c3b2f311230188117da1a4f86ceb61fbe127537 Mon Sep 17 00:00:00 2001 From: Wizznu Date: Wed, 18 Feb 2026 08:10:43 +0700 Subject: [PATCH] feat: Complete ApsGo hydroponics system with multiple features Features implemented: - Dashboard with real-time sensor monitoring (temp, humidity, light, soil moisture) - Water flow status indicator with color coding - Time-based scheduling (Kontrol Waktu/Jadwal) - Sensor-based threshold control (Kontrol Sensor/Threshold) - Smart mode with auto-stop when target reached - Three pump controls: Air, Larutan Nutrisi, Pengaduk - Manual control for all actuators - History/logging system - Firebase Realtime Database integration - Railway worker backend service UI/UX improvements: - Material Design 3 components - Badge indicators for active pumps - Conditional rendering (show only when active) - Responsive layouts with Wrap widgets - Color-coded status indicators Backend fixes: - Fixed water_flow reading from Firebase - Fixed threshold model copyWith method - Added pompa_pengaduk support across all models - Smart mode with per-pot independent monitoring - Cooldown per-threshold (not per-pot) - Valves stop together when targets reached Renamed: All 'Pupuk' references to 'Larutan Nutrisi Cair' --- lib/models/jadwal_model.dart | 6 + lib/models/threshold_model.dart | 10 +- lib/screens/dashboard_page.dart | 76 +++- lib/screens/jadwal_form_page.dart | 20 +- lib/screens/jadwal_management_page.dart | 171 +++++--- lib/screens/kontrol_page.dart | 3 +- lib/screens/pot_selection_page.dart | 3 +- lib/screens/threshold_form_page.dart | 101 +++-- lib/screens/threshold_management_page.dart | 246 +++++++++--- lib/services/firebase_database_service.dart | 2 + lib/services/threshold_service.dart | 54 ++- railway-worker/worker.js | 408 ++++++++++++++------ 12 files changed, 807 insertions(+), 293 deletions(-) diff --git a/lib/models/jadwal_model.dart b/lib/models/jadwal_model.dart index cbde3ed..17dc410 100644 --- a/lib/models/jadwal_model.dart +++ b/lib/models/jadwal_model.dart @@ -8,6 +8,7 @@ class JadwalModel { final List potAktif; // Array pot yang aktif [1, 2, 3, 4, 5] final bool pompaAir; final bool pompaPupuk; + final bool pompaPengaduk; JadwalModel({ required this.id, @@ -17,6 +18,7 @@ class JadwalModel { this.potAktif = const [], this.pompaAir = true, this.pompaPupuk = false, + this.pompaPengaduk = false, }); /// Create from Firebase JSON @@ -29,6 +31,7 @@ class JadwalModel { potAktif: _parsePotAktif(json['pot_aktif']), pompaAir: json['pompa_air'] ?? true, pompaPupuk: json['pompa_pupuk'] ?? false, + pompaPengaduk: json['pompa_pengaduk'] ?? false, ); } @@ -52,6 +55,7 @@ class JadwalModel { 'pot_aktif': potAktif, 'pompa_air': pompaAir, 'pompa_pupuk': pompaPupuk, + 'pompa_pengaduk': pompaPengaduk, }; } @@ -64,6 +68,7 @@ class JadwalModel { List? potAktif, bool? pompaAir, bool? pompaPupuk, + bool? pompaPengaduk, }) { return JadwalModel( id: id ?? this.id, @@ -73,6 +78,7 @@ class JadwalModel { potAktif: potAktif ?? this.potAktif, pompaAir: pompaAir ?? this.pompaAir, pompaPupuk: pompaPupuk ?? this.pompaPupuk, + pompaPengaduk: pompaPengaduk ?? this.pompaPengaduk, ); } diff --git a/lib/models/threshold_model.dart b/lib/models/threshold_model.dart index 290b3d2..5dd759e 100644 --- a/lib/models/threshold_model.dart +++ b/lib/models/threshold_model.dart @@ -8,6 +8,7 @@ class ThresholdModel { final List potAktif; // [1,2,3] atau [4,5] final bool pompaAir; final bool pompaPupuk; + final bool pompaPengaduk; ThresholdModel({ required this.id, @@ -19,6 +20,7 @@ class ThresholdModel { required this.potAktif, required this.pompaAir, required this.pompaPupuk, + required this.pompaPengaduk, }); // Parse dari Firebase @@ -26,7 +28,9 @@ class ThresholdModel { List parsePotAktif(dynamic potData) { if (potData == null) return []; if (potData is List) { - return potData.map((e) => e is int ? e : int.tryParse(e.toString()) ?? 0).toList(); + return potData + .map((e) => e is int ? e : int.tryParse(e.toString()) ?? 0) + .toList(); } return []; } @@ -41,6 +45,7 @@ class ThresholdModel { potAktif: parsePotAktif(json['pot_aktif']), pompaAir: json['pompa_air'] == true, pompaPupuk: json['pompa_pupuk'] == true, + pompaPengaduk: json['pompa_pengaduk'] == true, ); } @@ -55,6 +60,7 @@ class ThresholdModel { 'pot_aktif': potAktif, 'pompa_air': pompaAir, 'pompa_pupuk': pompaPupuk, + 'pompa_pengaduk': pompaPengaduk, }; } @@ -100,6 +106,7 @@ class ThresholdModel { List? potAktif, bool? pompaAir, bool? pompaPupuk, + bool? pompaPengaduk, }) { return ThresholdModel( id: id ?? this.id, @@ -111,6 +118,7 @@ class ThresholdModel { potAktif: potAktif ?? this.potAktif, pompaAir: pompaAir ?? this.pompaAir, pompaPupuk: pompaPupuk ?? this.pompaPupuk, + pompaPengaduk: pompaPengaduk ?? this.pompaPengaduk, ); } } diff --git a/lib/screens/dashboard_page.dart b/lib/screens/dashboard_page.dart index c928015..3a87973 100644 --- a/lib/screens/dashboard_page.dart +++ b/lib/screens/dashboard_page.dart @@ -336,6 +336,7 @@ class _DashboardPageState extends State { final suhu = data['suhu'] ?? '0'; final kelembapan = data['kelembapan'] ?? '0'; final ldr = data['ldr'] ?? '0'; + final waterFlow = data['water_flow'] ?? 0; final soil1 = data['soil_1'] ?? '0'; final soil2 = data['soil_2'] ?? '0'; final soil3 = data['soil_3'] ?? '0'; @@ -347,8 +348,14 @@ class _DashboardPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Connection Status Indicator - _buildConnectionStatus(), + // Connection Status & Water Status Indicators + Row( + children: [ + _buildConnectionStatus(), + const SizedBox(width: 12), + _buildWaterFlowStatus(waterFlow), + ], + ), const SizedBox(height: 8), // Sensor Cards - Temperature, Humidity, Light @@ -434,6 +441,71 @@ class _DashboardPageState extends State { ); } + Widget _buildWaterFlowStatus(dynamic waterFlow) { + // Debug: Print raw value + print( + 'šŸ” DEBUG Water Flow - Raw value: $waterFlow, Type: ${waterFlow.runtimeType}', + ); + + // Parse waterFlow to int - handles all cases + int flowValue = 0; + + if (waterFlow == null) { + flowValue = 0; + print(' → Water flow is NULL'); + } else if (waterFlow is int) { + flowValue = waterFlow; + print(' → Parsed as int: $flowValue'); + } else if (waterFlow is double) { + flowValue = waterFlow.toInt(); + print(' → Parsed as double to int: $flowValue'); + } else if (waterFlow is String) { + flowValue = int.tryParse(waterFlow) ?? 0; + print(' → Parsed from string: $flowValue'); + } else { + final stringValue = waterFlow.toString(); + flowValue = int.tryParse(stringValue) ?? 0; + print(' → Parsed from toString(): $flowValue'); + } + + final hasWater = flowValue > 0; + print(' → Final: flowValue=$flowValue, hasWater=$hasWater'); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: + hasWater + ? Colors.green.withOpacity(0.1) + : Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: hasWater ? Colors.green : Colors.red, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.water_drop, + size: 14, + color: hasWater ? Colors.green : Colors.red, + ), + const SizedBox(width: 8), + Text( + hasWater ? 'Air Tersedia' : 'Air Habis', + style: TextStyle( + color: hasWater ? Colors.green : Colors.red, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + Widget _buildSensorCard({ required String title, required String value, diff --git a/lib/screens/jadwal_form_page.dart b/lib/screens/jadwal_form_page.dart index ac0d9e4..ebb4661 100644 --- a/lib/screens/jadwal_form_page.dart +++ b/lib/screens/jadwal_form_page.dart @@ -24,6 +24,7 @@ class _JadwalFormPageState extends State { late List _potSelection; // [pot1, pot2, pot3, pot4, pot5] late bool _pompaAir; late bool _pompaPupuk; + late bool _pompaPengaduk; late bool _aktif; bool _isLoading = false; @@ -58,6 +59,7 @@ class _JadwalFormPageState extends State { _pompaAir = jadwal.pompaAir; _pompaPupuk = jadwal.pompaPupuk; + _pompaPengaduk = jadwal.pompaPengaduk; _aktif = jadwal.aktif; } else { // Default values for new jadwal @@ -67,6 +69,7 @@ class _JadwalFormPageState extends State { _potSelection = [false, false, false, false, false]; _pompaAir = true; _pompaPupuk = false; + _pompaPengaduk = false; _aktif = true; } } @@ -150,6 +153,7 @@ class _JadwalFormPageState extends State { potAktif: _selectedPots, pompaAir: _pompaAir, pompaPupuk: _pompaPupuk, + pompaPengaduk: _pompaPengaduk, ); // Check if time slot is taken (for new or time changed) @@ -603,14 +607,26 @@ class _JadwalFormPageState extends State { SwitchListTile( value: _pompaPupuk, onChanged: (value) => setState(() => _pompaPupuk = value), - title: const Text('Pompa Pupuk'), - subtitle: const Text('Mengalirkan pupuk/nutrisi'), + title: const Text('Pompa Larutan Nutrisi'), + subtitle: const Text('Mengalirkan larutan nutrisi cair'), secondary: Icon( Icons.science, color: _pompaPupuk ? Colors.orange : Colors.grey, ), activeColor: AppColors.primaryGreen, ), + const Divider(), + SwitchListTile( + value: _pompaPengaduk, + onChanged: (value) => setState(() => _pompaPengaduk = value), + title: const Text('Pompa Pengaduk'), + subtitle: const Text('Mengaduk larutan nutrisi'), + secondary: Icon( + Icons.blender, + color: _pompaPengaduk ? Colors.purple : Colors.grey, + ), + activeColor: AppColors.primaryGreen, + ), ], ), ); diff --git a/lib/screens/jadwal_management_page.dart b/lib/screens/jadwal_management_page.dart index b78e8b6..7441195 100644 --- a/lib/screens/jadwal_management_page.dart +++ b/lib/screens/jadwal_management_page.dart @@ -165,12 +165,6 @@ class _JadwalManagementPageState extends State { ), ], ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _navigateToForm(), - backgroundColor: AppColors.primaryGreen, - icon: const Icon(Icons.add), - label: const Text('Tambah Jadwal'), - ), ); } @@ -265,8 +259,30 @@ class _JadwalManagementPageState extends State { onRefresh: _loadJadwal, child: ListView.builder( padding: const EdgeInsets.all(16), - itemCount: _jadwalList.length, + itemCount: _jadwalList.length + 1, // +1 for add button itemBuilder: (context, index) { + if (index == _jadwalList.length) { + // Add button at the bottom + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 80), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _navigateToForm(), + icon: const Icon(Icons.add), + label: const Text('Tambah Jadwal Baru'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ); + } final jadwal = _jadwalList[index]; return _buildJadwalCard(jadwal); }, @@ -369,24 +385,97 @@ class _JadwalManagementPageState extends State { ), const SizedBox(height: 12), - // Pumps - Row( + // Pumps - Display only if enabled + Wrap( + spacing: 8, + runSpacing: 8, children: [ - Expanded( - child: _buildPumpChip( - 'Pompa Air', - jadwal.pompaAir, - Icons.water_drop, + if (jadwal.pompaAir) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue[300]!), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.water, + size: 14, + color: Colors.blue[700], + ), + const SizedBox(width: 4), + Text( + 'Air', + style: TextStyle( + fontSize: 12, + color: Colors.blue[900], + ), + ), + ], + ), ), - ), - const SizedBox(width: 8), - Expanded( - child: _buildPumpChip( - 'Pompa Pupuk', - jadwal.pompaPupuk, - Icons.science, + if (jadwal.pompaPupuk) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.green[50], + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.green[300]!), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.eco, size: 14, color: Colors.green[700]), + const SizedBox(width: 4), + Text( + 'Larutan Nutrisi', + style: TextStyle( + fontSize: 12, + color: Colors.green[900], + ), + ), + ], + ), + ), + if (jadwal.pompaPengaduk) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.purple[50], + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.purple[300]!), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.blender, + size: 14, + color: Colors.purple[700], + ), + const SizedBox(width: 4), + Text( + 'Pengaduk', + style: TextStyle( + fontSize: 12, + color: Colors.purple[900], + ), + ), + ], + ), ), - ), ], ), @@ -450,42 +539,4 @@ class _JadwalManagementPageState extends State { ], ); } - - Widget _buildPumpChip(String label, bool enabled, IconData icon) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: - enabled - ? AppColors.primaryGreen.withOpacity(0.1) - : Colors.grey[200], - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: enabled ? AppColors.primaryGreen : Colors.grey[400]!, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 16, - color: enabled ? AppColors.primaryGreen : Colors.grey[600], - ), - const SizedBox(width: 6), - Expanded( - child: Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: enabled ? AppColors.primaryGreen : Colors.grey[600], - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } } diff --git a/lib/screens/kontrol_page.dart b/lib/screens/kontrol_page.dart index 658ce45..17bea06 100644 --- a/lib/screens/kontrol_page.dart +++ b/lib/screens/kontrol_page.dart @@ -83,7 +83,8 @@ class _KontrolPageState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => const ThresholdManagementPage(), + builder: + (context) => const ThresholdManagementPage(), ), ); }, diff --git a/lib/screens/pot_selection_page.dart b/lib/screens/pot_selection_page.dart index e7d0114..3ff9bec 100644 --- a/lib/screens/pot_selection_page.dart +++ b/lib/screens/pot_selection_page.dart @@ -156,7 +156,8 @@ class _PotSelectionPageState extends State { onPressed: () { Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (context) => const ThresholdManagementPage(), + builder: + (context) => const ThresholdManagementPage(), ), ); }, diff --git a/lib/screens/threshold_form_page.dart b/lib/screens/threshold_form_page.dart index a6608cd..e439550 100644 --- a/lib/screens/threshold_form_page.dart +++ b/lib/screens/threshold_form_page.dart @@ -25,6 +25,7 @@ class _ThresholdFormPageState extends State { late List _potSelection; // [pot1, pot2, pot3, pot4, pot5] late bool _pompaAir; late bool _pompaPupuk; + late bool _pompaPengaduk; late bool _aktif; bool _isLoading = false; @@ -54,6 +55,7 @@ class _ThresholdFormPageState extends State { _pompaAir = threshold.pompaAir; _pompaPupuk = threshold.pompaPupuk; + _pompaPengaduk = threshold.pompaPengaduk; _aktif = threshold.aktif; } else { // Default values for new threshold @@ -65,6 +67,7 @@ class _ThresholdFormPageState extends State { _potSelection = [false, false, false, false, false]; _pompaAir = true; _pompaPupuk = false; + _pompaPengaduk = false; _aktif = true; } } @@ -130,6 +133,7 @@ class _ThresholdFormPageState extends State { potAktif: _selectedPots, pompaAir: _pompaAir, pompaPupuk: _pompaPupuk, + pompaPengaduk: _pompaPengaduk, ); // Save to Firebase @@ -185,27 +189,28 @@ class _ThresholdFormPageState extends State { title: Text(_isEditMode ? 'Edit Threshold' : 'Tambah Threshold'), centerTitle: true, ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : Form( - key: _formKey, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildRangeSection(), - const SizedBox(height: 24), - _buildModeSection(), - const SizedBox(height: 24), - _buildPotSelection(), - const SizedBox(height: 24), - _buildPumpSection(), - const SizedBox(height: 24), - _buildStatusSection(), - const SizedBox(height: 32), - _buildSaveButton(), - ], + body: + _isLoading + ? const Center(child: CircularProgressIndicator()) + : Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildRangeSection(), + const SizedBox(height: 24), + _buildModeSection(), + const SizedBox(height: 24), + _buildPotSelection(), + const SizedBox(height: 24), + _buildPumpSection(), + const SizedBox(height: 24), + _buildStatusSection(), + const SizedBox(height: 32), + _buildSaveButton(), + ], + ), ), - ), ); } @@ -527,8 +532,25 @@ class _ThresholdFormPageState extends State { _pompaPupuk = value; }); }, - title: const Text('Pompa Pupuk'), - subtitle: const Text('Aktifkan pompa pupuk saat penyiraman'), + title: const Text('Pompa Larutan Nutrisi'), + subtitle: const Text( + 'Aktifkan pompa larutan nutrisi saat penyiraman', + ), + activeColor: AppColor.primary, + contentPadding: EdgeInsets.zero, + ), + + SwitchListTile( + value: _pompaPengaduk, + onChanged: (value) { + setState(() { + _pompaPengaduk = value; + }); + }, + title: const Text('Pompa Pengaduk'), + subtitle: const Text( + 'Aktifkan pompa pengaduk untuk mengaduk larutan', + ), activeColor: AppColor.primary, contentPadding: EdgeInsets.zero, ), @@ -567,27 +589,26 @@ class _ThresholdFormPageState extends State { backgroundColor: AppColor.primary, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 2, ), - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + child: + _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + _isEditMode ? 'Update Threshold' : 'Simpan Threshold', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), - ) - : Text( - _isEditMode ? 'Update Threshold' : 'Simpan Threshold', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), ); } } diff --git a/lib/screens/threshold_management_page.dart b/lib/screens/threshold_management_page.dart index 3aca2a4..4b07ddb 100644 --- a/lib/screens/threshold_management_page.dart +++ b/lib/screens/threshold_management_page.dart @@ -22,6 +22,7 @@ class _ThresholdManagementPageState extends State { void initState() { super.initState(); _loadThreshold(); + _loadSensorModeStatus(); } Future _loadThreshold() async { @@ -38,6 +39,23 @@ class _ThresholdManagementPageState extends State { } } + Future _loadSensorModeStatus() async { + final status = await ThresholdService.getSensorModeStatus(); + setState(() => _sensorModeEnabled = status); + } + + Future _toggleSensorMode(bool value) async { + final success = await ThresholdService.setSensorModeStatus(value); + if (success) { + setState(() => _sensorModeEnabled = value); + _showSuccess( + value ? 'Mode Sensor Diaktifkan' : 'Mode Sensor Dinonaktifkan', + ); + } else { + _showError('Gagal mengubah mode sensor'); + } + } + Future _toggleThresholdAktif(ThresholdModel threshold) async { final newStatus = !threshold.aktif; final success = await ThresholdService.toggleThresholdAktif( @@ -98,20 +116,21 @@ class _ThresholdManagementPageState extends State { Future _showConfirmDialog(String title, String message) { return showDialog( context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Batal'), + builder: + (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Hapus', style: TextStyle(color: Colors.red)), + ), + ], ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Hapus', style: TextStyle(color: Colors.red)), - ), - ], - ), ); } @@ -152,19 +171,79 @@ class _ThresholdManagementPageState extends State { ), ], ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : RefreshIndicator( - onRefresh: _loadThreshold, - child: _thresholdList.isEmpty - ? _buildEmptyState() - : _buildThresholdList(), + body: Column( + children: [ + _buildSensorModeCard(), + Expanded( + child: + _isLoading + ? const Center(child: CircularProgressIndicator()) + : _thresholdList.isEmpty + ? _buildEmptyState() + : _buildThresholdList(), + ), + ], + ), + ); + } + + Widget _buildSensorModeCard() { + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + _sensorModeEnabled + ? AppColor.primary.withOpacity(0.1) + : Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _navigateToForm(), - backgroundColor: AppColor.primary, - icon: const Icon(Icons.add), - label: const Text('Tambah Threshold'), + child: Icon( + Icons.sensors, + color: _sensorModeEnabled ? AppColor.primary : Colors.grey, + size: 28, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Mode Sensor', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + _sensorModeEnabled + ? 'Monitoring otomatis aktif' + : 'Monitoring otomatis nonaktif', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ), + Switch( + value: _sensorModeEnabled, + onChanged: _toggleSensorMode, + activeColor: AppColor.primary, + ), + ], ), ); } @@ -195,13 +274,38 @@ class _ThresholdManagementPageState extends State { } Widget _buildThresholdList() { - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _thresholdList.length, - itemBuilder: (context, index) { - final threshold = _thresholdList[index]; - return _buildThresholdCard(threshold); - }, + return RefreshIndicator( + onRefresh: _loadThreshold, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _thresholdList.length + 1, // +1 for add button + itemBuilder: (context, index) { + if (index == _thresholdList.length) { + // Add button at the bottom + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 80), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _navigateToForm(), + icon: const Icon(Icons.add), + label: const Text('Tambah Threshold Baru'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColor.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ); + } + final threshold = _thresholdList[index]; + return _buildThresholdCard(threshold); + }, + ), ); } @@ -233,7 +337,10 @@ class _ThresholdManagementPageState extends State { ), child: Icon( Icons.sensors, - color: threshold.aktif ? AppColors.primaryGreen : Colors.grey, + color: + threshold.aktif + ? AppColors.primaryGreen + : Colors.grey, size: 24, ), ), @@ -383,7 +490,7 @@ class _ThresholdManagementPageState extends State { Icon(Icons.eco, size: 14, color: Colors.green[700]), const SizedBox(width: 4), Text( - 'Pupuk', + 'Larutan Nutrisi', style: TextStyle( fontSize: 12, color: Colors.green[900], @@ -392,6 +499,39 @@ class _ThresholdManagementPageState extends State { ], ), ), + if ((threshold.pompaAir || threshold.pompaPupuk) && + threshold.pompaPengaduk) + const SizedBox(width: 8), + if (threshold.pompaPengaduk) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.purple[50], + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.purple[300]!), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.blender, + size: 14, + color: Colors.purple[700], + ), + const SizedBox(width: 4), + Text( + 'Pengaduk', + style: TextStyle( + fontSize: 12, + color: Colors.purple[900], + ), + ), + ], + ), + ), ], ), @@ -399,33 +539,33 @@ class _ThresholdManagementPageState extends State { // Action buttons Row( - mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton.icon( - onPressed: () => _navigateToForm(threshold: threshold), - icon: const Icon(Icons.edit, size: 18), - label: const Text('Edit'), - style: TextButton.styleFrom( - foregroundColor: Colors.blue, + Expanded( + child: OutlinedButton.icon( + onPressed: () => _navigateToForm(threshold: threshold), + icon: const Icon(Icons.edit, size: 18), + label: const Text('Edit'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColor.primary, + ), ), ), const SizedBox(width: 8), - TextButton.icon( - onPressed: () => _duplicateThreshold(threshold), - icon: const Icon(Icons.copy, size: 18), - label: const Text('Duplikat'), - style: TextButton.styleFrom( - foregroundColor: Colors.orange, + Expanded( + child: OutlinedButton.icon( + onPressed: () => _duplicateThreshold(threshold), + icon: const Icon(Icons.copy, size: 18), + label: const Text('Duplikat'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.blue, + ), ), ), const SizedBox(width: 8), - TextButton.icon( + IconButton( onPressed: () => _deleteThreshold(threshold), - icon: const Icon(Icons.delete, size: 18), - label: const Text('Hapus'), - style: TextButton.styleFrom( - foregroundColor: Colors.red, - ), + icon: const Icon(Icons.delete), + color: Colors.red, ), ], ), diff --git a/lib/services/firebase_database_service.dart b/lib/services/firebase_database_service.dart index 9f3a16a..00507ff 100644 --- a/lib/services/firebase_database_service.dart +++ b/lib/services/firebase_database_service.dart @@ -19,6 +19,7 @@ class FirebaseDatabaseService { 'suhu': _parseValue(data['suhu']), 'kelembapan': _parseValue(data['kelembapan']), 'ldr': _parseValue(data['ldr']), + 'water_flow': data['water_flow'] ?? 0, // TAMBAHAN: water flow sensor 'soil_1': _parseValue(data['soil_1']), 'soil_2': _parseValue(data['soil_2']), 'soil_3': _parseValue(data['soil_3']), @@ -40,6 +41,7 @@ class FirebaseDatabaseService { 'suhu': _parseValue(data['suhu']), 'kelembapan': _parseValue(data['kelembapan']), 'ldr': _parseValue(data['ldr']), + 'water_flow': data['water_flow'] ?? 0, // TAMBAHAN: water flow sensor 'soil_1': _parseValue(data['soil_1']), 'soil_2': _parseValue(data['soil_2']), 'soil_3': _parseValue(data['soil_3']), diff --git a/lib/services/threshold_service.dart b/lib/services/threshold_service.dart index 3c025f5..e8431c9 100644 --- a/lib/services/threshold_service.dart +++ b/lib/services/threshold_service.dart @@ -9,7 +9,7 @@ class ThresholdService { static Future> getAllThreshold() async { try { final snapshot = await _dbRef.child(kontrolPath).get(); - + if (!snapshot.exists) { return []; } @@ -87,7 +87,7 @@ class ThresholdService { static Future duplicateThreshold(ThresholdModel threshold) async { try { final allThresholds = await getAllThreshold(); - + // Cari ID tertinggi int maxId = 0; for (var t in allThresholds) { @@ -113,7 +113,7 @@ class ThresholdService { static Future getThreshold(String id) async { try { final snapshot = await _dbRef.child(kontrolPath).child(id).get(); - + if (!snapshot.exists) return null; final data = snapshot.value as Map?; @@ -169,22 +169,25 @@ class ThresholdService { ) async { try { final allThresholds = await getAllThreshold(); - + for (var threshold in allThresholds) { // Skip threshold yang sama if (threshold.id == currentId) continue; - + // Cek apakah ada pot yang sama - final hasSamePot = threshold.potAktif.any((pot) => potAktif.contains(pot)); + final hasSamePot = threshold.potAktif.any( + (pot) => potAktif.contains(pot), + ); if (!hasSamePot) continue; - + // Cek apakah range overlap - final rangeOverlap = !(batasAtas < threshold.batasBawah || - batasBawah > threshold.batasAtas); - + final rangeOverlap = + !(batasAtas < threshold.batasBawah || + batasBawah > threshold.batasAtas); + if (rangeOverlap) return true; } - + return false; } catch (e) { print('Error checking range overlap: $e'); @@ -196,20 +199,43 @@ class ThresholdService { static Future getNextThresholdId() async { try { final allThresholds = await getAllThreshold(); - + if (allThresholds.isEmpty) return 'threshold_1'; - + // Cari ID tertinggi int maxId = 0; for (var t in allThresholds) { final num = int.tryParse(t.id.replaceAll('threshold_', '')) ?? 0; if (num > maxId) maxId = num; } - + return 'threshold_${maxId + 1}'; } catch (e) { print('Error getting next threshold ID: $e'); return 'threshold_1'; } } + + // Get sensor mode status (otomatis) + static Future getSensorModeStatus() async { + try { + final snapshot = await _dbRef.child(kontrolPath).child('otomatis').get(); + return snapshot.value as bool? ?? false; + } catch (e) { + print('Error getting sensor mode status: $e'); + return false; + } + } + + // Set sensor mode status (otomatis) + static Future setSensorModeStatus(bool enabled) async { + try { + await _dbRef.child(kontrolPath).child('otomatis').set(enabled); + print('āœ… Sensor mode set to ${enabled ? "enabled" : "disabled"}'); + return true; + } catch (e) { + print('āŒ Error setting sensor mode: $e'); + return false; + } + } } diff --git a/railway-worker/worker.js b/railway-worker/worker.js index ea0ad1c..4d5ef50 100644 --- a/railway-worker/worker.js +++ b/railway-worker/worker.js @@ -133,20 +133,24 @@ const wateringQueue = new Queue('watering', { connection: redis }); redis.on('connect', () => console.log('āœ… Redis connected')); redis.on('error', (err) => console.error('āŒ Redis error:', err.message)); -// Track last watering time untuk prevent spam -const lastWateringTime = {}; +// Track last watering time PER-THRESHOLD (not per-pot!) untuk prevent spam +const lastThresholdTime = {}; // ==================== WATERING WORKER ==================== const wateringWorker = new Worker( 'watering', async (job) => { - const { type, potNumbers, pompaAir, pompaPupuk, duration, scheduleId } = job.data; + const { type, potNumbers, pompaAir, pompaPupuk, duration, scheduleId, thresholdId, smartMode, sensorData } = job.data; console.log(`\nšŸ’§ Processing Job: ${job.id}`); console.log(` Type: ${type}`); console.log(` Pots: [${potNumbers.join(', ')}]`); - console.log(` Duration: ${duration}s`); + console.log(` Mode: ${smartMode ? 'SMART (auto-stop at target)' : 'FIXED'}`); + console.log(` Duration: ${duration}s ${smartMode ? '(max)' : ''}`); + if (sensorData) { + console.log(` Target: ${sensorData.batasBawah}% → ${sensorData.batasAtas}%`); + } try { // Prepare aktuator updates @@ -167,37 +171,120 @@ const wateringWorker = new Worker( console.log(' šŸ“ Updates:', JSON.stringify(updates, null, 2)); await updateFirebaseSmart('aktuator', updates); + console.log(` šŸš€ ALL VALVES STARTED SIMULTANEOUSLY: ${Object.keys(updates).filter(k => k.startsWith('mosvet_')).join(', ')}`); - // (Verification skipped to avoid SDK timeout - update success already logged above) - - // Wait for duration with progress logging - const startTime = Date.now(); - const endTime = startTime + duration * 1000; - - while (Date.now() < endTime) { - const remaining = Math.ceil((endTime - Date.now()) / 1000); - if (remaining % 10 === 0 || remaining <= 5) { - console.log(` ā³ ${remaining}s remaining...`); + // SMART MODE: Monitor sensor and stop pots TOGETHER when they reach target + if (smartMode && sensorData && sensorData.batasAtas) { + const targetSoil = sensorData.batasAtas; + const maxDuration = duration * 1000; // Convert to ms + const startTime = Date.now(); + + // Track which pots are still actively watering + let activePots = [...potNumbers]; + + console.log(` šŸŽÆ SMART MODE: Monitoring ${activePots.length} pots, target ${targetSoil}%...`); + console.log(` ⚔ Valves will stop TOGETHER when pots reach target (checked every 2s)`); + + while (activePots.length > 0 && Date.now() - startTime < maxDuration) { + await sleep(2000); // Check every 2 seconds + + try { + const currentSensorData = await readFirebaseSmart('data'); + + if (currentSensorData) { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + const potsToStop = []; + + // Check ALL active pots and collect which ones reached target + for (const pot of activePots) { + const soilKey = `soil_${pot}`; + const currentValue = parseInt(currentSensorData[soilKey]) || 0; + + if (currentValue >= targetSoil) { + console.log(` āœ… [${elapsed}s] POT ${pot}: ${currentValue}% >= ${targetSoil}% - TARGET REACHED!`); + potsToStop.push(pot); + } else { + console.log(` ā³ [${elapsed}s] POT ${pot}: ${currentValue}% < ${targetSoil}% - continuing...`); + } + } + + // Stop ALL pots that reached target TOGETHER (not one-by-one!) + if (potsToStop.length > 0) { + const stopUpdates = {}; + for (const pot of potsToStop) { + stopUpdates[`mosvet_${pot + 2}`] = false; + } + + await updateFirebaseSmart('aktuator', stopUpdates); + console.log(` šŸ”“ STOPPED TOGETHER: ${Object.keys(stopUpdates).join(', ')} (Pots: [${potsToStop.join(', ')}])`); + + // Remove stopped pots from active list + activePots = activePots.filter(p => !potsToStop.includes(p)); + } + + if (activePots.length === 0) { + console.log(` šŸŽ‰ All pots reached target! Smart watering complete.`); + } else { + console.log(` šŸ“ Still watering: [${activePots.join(', ')}]`); + } + } + } catch (sensorError) { + console.warn(` āš ļø Failed to read sensor: ${sensorError.message}`); + } } - await sleep(1000); - } + + // If any pots still active after max duration (timeout), stop them now TOGETHER + if (activePots.length > 0) { + console.log(` ā±ļø Max duration ${duration}s reached. Force stopping remaining pots: [${activePots.join(', ')}]`); + const timeoutStops = {}; + for (const pot of activePots) { + timeoutStops[`mosvet_${pot + 2}`] = false; + } + await updateFirebaseSmart('aktuator', timeoutStops); + console.log(` šŸ”“ Force stopped TOGETHER: ${Object.keys(timeoutStops).join(', ')}`); + } + + // Finally, stop pumps + const pumpStop = {}; + if (pompaAir) pumpStop['mosvet_1'] = false; + if (pompaPupuk) pumpStop['mosvet_2'] = false; + if (Object.keys(pumpStop).length > 0) { + await updateFirebaseSmart('aktuator', pumpStop); + console.log(' šŸ”“ Pumps stopped:', Object.keys(pumpStop).join(', ')); + } + console.log(' āœ… Smart mode completed, now logging history...'); + + } else { + // FIXED MODE: Wait for fixed duration + const startTime = Date.now(); + const endTime = startTime + duration * 1000; - // Turn OFF - const offUpdates = {}; - for (const key in updates) { - offUpdates[key] = false; + while (Date.now() < endTime) { + const remaining = Math.ceil((endTime - Date.now()) / 1000); + if (remaining % 10 === 0 || remaining <= 5) { + console.log(` ā³ ${remaining}s remaining...`); + } + await sleep(1000); + } + + // Turn OFF all at once (FIXED mode only) + const offUpdates = {}; + for (const key in updates) { + offUpdates[key] = false; + } + console.log(' šŸ”“ Turning OFF:', Object.keys(offUpdates).join(', ')); + await updateFirebaseSmart('aktuator', offUpdates); + console.log(' āœ… Turn OFF completed, now logging history...'); } - console.log(' šŸ”“ Turning OFF:', Object.keys(offUpdates).join(', ')); - await updateFirebaseSmart('aktuator', offUpdates); - console.log(' āœ… Turn OFF completed, now logging history...'); // Log history await logHistory(type, potNumbers, duration); console.log(' āœ… History logged successfully'); - // Update last watering time - for (const pot of potNumbers) { - lastWateringTime[`pot_${pot}`] = Date.now(); + // Update last watering time PER-THRESHOLD (not per-pot!) + if (thresholdId) { + lastThresholdTime[thresholdId] = Date.now(); + console.log(` ā° Cooldown set for ${thresholdId} (2 minutes)`); } console.log(` āœ… Job completed successfully`); @@ -786,116 +873,199 @@ setTimeout(async () => { let sensorCheckCounter = 0; -async function setupSensorMonitoring() { - console.log('āœ… Sensor Mode (Threshold System) monitoring started'); - - db.ref('data').on('value', async (snapshot) => { - sensorCheckCounter++; +// Core sensor check logic (shared by listener and polling) +async function checkSensorThresholds() { + sensorCheckCounter++; + + try { + // Fetch sensor data using smart method (REST fallback) + const sensorData = await readFirebaseSmart('data'); - try { - const sensorData = snapshot.val(); - if (!sensorData) { - console.log('āš ļø Sensor data is null/empty'); - return; - } + if (!sensorData) { + console.log('āš ļø Sensor data is null/empty - ESP32 might not be sending data'); + return; + } - const configSnapshot = await fetchWithTimeout(db.ref(FIREBASE_PATHS.kontrol), 10000); - const kontrolConfig = configSnapshot.val(); + // Fetch kontrol config using smart method + const kontrolConfig = await fetchKontrolSmart(); - if (!kontrolConfig) { - return; - } - - // Detect all threshold_* nodes - const allThresholds = Object.keys(kontrolConfig).filter(key => key.startsWith('threshold_')); - - // Log sensor check (verbose hanya setiap 10 kali) + if (!kontrolConfig) { + console.log('āš ļø Kontrol config is null'); + return; + } + + // Check if sensor mode is enabled (using 'otomatis' field, not deprecated 'sensor') + if (!kontrolConfig.otomatis) { if (sensorCheckCounter % 10 === 0) { - console.log(`\nšŸŒ”ļø SENSOR CHECK #${sensorCheckCounter} | Total Thresholds: ${allThresholds.length}`); + console.log(`āš ļø Sensor mode DISABLED (otomatis=false). Skipping threshold check.`); + } + return; + } + + // Detect all threshold_* nodes + const allThresholds = Object.keys(kontrolConfig).filter(key => key.startsWith('threshold_')); + + // Log sensor check (ALWAYS LOG untuk debugging) + console.log(`\nšŸŒ”ļø SENSOR CHECK #${sensorCheckCounter} | Total Thresholds: ${allThresholds.length}`); + console.log(` šŸ“Š Sensor Data:`, JSON.stringify(sensorData, null, 2)); + + if (allThresholds.length === 0) { + console.log(' āš ļø No thresholds configured'); + return; + } + + // Process each threshold + for (const thresholdKey of allThresholds) { + const threshold = kontrolConfig[thresholdKey]; + + console.log(`\n šŸ” Checking ${thresholdKey}:`); + console.log(` Config:`, JSON.stringify(threshold, null, 2)); + + // Skip if threshold is not active or invalid + if (!threshold || !threshold.aktif) { + console.log(` āŒ Skipped: ${!threshold ? 'Not found' : 'Not active (aktif=false)'}`); + continue; } - if (allThresholds.length === 0) { - // No thresholds configured - return; + // Check THRESHOLD cooldown (not per-pot!) + const lastTime = lastThresholdTime[thresholdKey]; + if (lastTime && Date.now() - lastTime < config.worker.sensorDebounce) { + const remainingSeconds = Math.ceil((config.worker.sensorDebounce - (Date.now() - lastTime)) / 1000); + console.log(` ā³ ${thresholdKey}: Cooldown active (${remainingSeconds}s remaining) - skipping entire threshold`); + continue; } - // Process each threshold - for (const thresholdKey of allThresholds) { - const threshold = kontrolConfig[thresholdKey]; - - // Skip if threshold is not active or invalid - if (!threshold || !threshold.aktif) continue; + const batasBawah = threshold.batas_bawah || 30; + const batasAtas = threshold.batas_atas || 70; + const durasi = threshold.durasi || 600; + const smartMode = threshold.smart_mode === true; + const potAktif = threshold.pot_aktif || []; + const pompaAir = threshold.pompa_air === true; + const pompaPupuk = threshold.pompa_pupuk === true; - const batasBawah = threshold.batas_bawah || 30; - const batasAtas = threshold.batas_atas || 70; - const durasi = threshold.durasi || 600; - const smartMode = threshold.smart_mode === true; - const potAktif = threshold.pot_aktif || []; - const pompaAir = threshold.pompa_air === true; - const pompaPupuk = threshold.pompa_pupuk === true; + // Collect pots that need watering in this threshold + const potsNeedWatering = []; + const potDetails = []; - // Check each pot in this threshold - for (const potNumber of potAktif) { - if (potNumber < 1 || potNumber > 5) continue; + // Check each pot in this threshold + for (const potNumber of potAktif) { + if (potNumber < 1 || potNumber > 5) { + console.log(` āš ļø POT ${potNumber}: Invalid pot number (must be 1-5)`); + continue; + } - const soilKey = `soil_${potNumber}`; - const soilValue = parseInt(sensorData[soilKey]) || 0; + const soilKey = `soil_${potNumber}`; + const soilValue = parseInt(sensorData[soilKey]) || 0; - // Check if below threshold - if (soilValue < batasBawah) { - const potKey = `pot_${potNumber}`; - const lastTime = lastWateringTime[potKey]; + console.log(` 🌱 POT ${potNumber} (${soilKey}): ${soilValue}% | Threshold: ${batasBawah}-${batasAtas}%`); + console.log(` → Raw value: ${sensorData[soilKey]} | Parsed: ${soilValue} | Check: ${soilValue} < ${batasBawah} = ${soilValue < batasBawah}`); - // Debounce: minimum 2 menit antar penyiraman - if (lastTime && Date.now() - lastTime < config.worker.sensorDebounce) { - const remainingSeconds = Math.ceil((config.worker.sensorDebounce - (Date.now() - lastTime)) / 1000); - if (sensorCheckCounter % 10 === 0) { - console.log(`ā³ ${thresholdKey.toUpperCase()} - POT ${potNumber}: Cooldown (${remainingSeconds}s)`); - } - continue; - } + // NEW: Check if ABOVE upper threshold - skip if too wet! + if (soilValue >= batasAtas) { + console.log(` āœ… POT ${potNumber}: SKIP (${soilValue}% >= ${batasAtas}% - sudah basah!)`); + continue; + } - console.log(`\nšŸŒ”ļø THRESHOLD TRIGGERED: ${thresholdKey.toUpperCase()}`); - console.log(` POT ${potNumber}: ${soilValue}% < ${batasBawah}%`); - console.log(` Mode: ${smartMode ? 'Smart' : 'Fixed'}, Target: ${smartMode ? batasAtas + '%' : durasi + 's'}`); - console.log(` Pumps: Air=${pompaAir}, Pupuk=${pompaPupuk}`); - - const jobId = `${thresholdKey}-pot-${potNumber}-${Date.now()}`; - await wateringQueue.add( - `threshold-pot-${potNumber}`, - { - type: 'sensor_threshold', - potNumbers: [potNumber], - pompaAir: pompaAir, - pompaPupuk: pompaPupuk, - duration: durasi, - scheduleId: jobId, - thresholdId: thresholdKey, - sensorData: { - soilValue, - batasBawah, - batasAtas, - mode: smartMode ? 'smart' : 'fixed' - }, - }, - { - jobId, - removeOnComplete: true, - priority: 1, // Higher priority for sensor-triggered - } - ); - - console.log(` šŸ“Œ Added to queue: ${jobId}`); - - // Update last watering time to prevent rapid re-triggering - lastWateringTime[potKey] = Date.now(); - } + // Check if below lower threshold + if (soilValue < batasBawah) { + console.log(` 🚨 POT ${potNumber} KERING! ${soilValue}% < ${batasBawah}%`); + + // Add to watering list (cooldown already checked at threshold level) + potsNeedWatering.push(potNumber); + potDetails.push({ pot: potNumber, value: soilValue }); + } else { + console.log(` āœ… POT ${potNumber}: OK (${soilValue}% >= ${batasBawah}%)`); } } - } catch (error) { - console.error('āŒ Error in threshold monitoring:', error.message); + + // NEW: Create SINGLE job for ALL pots that need watering in this threshold + if (potsNeedWatering.length > 0) { + console.log(`\nšŸŒ”ļø THRESHOLD TRIGGERED: ${thresholdKey.toUpperCase()}`); + console.log(` Pots needing water: [${potsNeedWatering.join(', ')}]`); + potDetails.forEach(p => console.log(` - POT ${p.pot}: ${p.value}% < ${batasBawah}%`)); + console.log(` Mode: ${smartMode ? 'Smart (monitor until ' + batasAtas + '%)' : 'Fixed (' + durasi + 's)'}`); + console.log(` Pumps: Air=${pompaAir}, Pupuk=${pompaPupuk}`); + + const jobId = `${thresholdKey}-${Date.now()}`; + await wateringQueue.add( + thresholdKey, + { + type: 'sensor_threshold', + potNumbers: potsNeedWatering, // ALL pots in 1 job! + pompaAir: pompaAir, + pompaPupuk: pompaPupuk, + duration: durasi, + scheduleId: jobId, + thresholdId: thresholdKey, + smartMode: smartMode, + sensorData: { + batasBawah, + batasAtas, + mode: smartMode ? 'smart' : 'fixed', + potValues: potDetails + }, + }, + { + jobId, + removeOnComplete: true, + priority: 1, // Higher priority for sensor-triggered + } + ); + + console.log(` šŸ“Œ Added to queue: ${jobId}`); + console.log(` šŸ”„ ${thresholdKey} will execute simultaneously for ALL pots`); + console.log(` ā° After completion, ${thresholdKey} cooldown = 2 minutes (other thresholds can still run)`); + } } - }); + } catch (error) { + console.error('āŒ Error in sensor threshold check:', error.message); + } +} + +// Setup sensor monitoring with BOTH listener (SDK) and polling (fallback) +async function setupSensorMonitoring() { + console.log('šŸŒ”ļø ==================== SENSOR MODE ENABLED ===================='); + console.log('āœ… Sensor Mode (Threshold System) monitoring started'); + console.log('šŸ“ Primary: Polling every 30 seconds (REST API)'); + console.log('šŸ“ Backup: Firebase listener on /data (if SDK works)'); + console.log('šŸ“ Config path: /' + FIREBASE_PATHS.kontrol); + console.log('ā±ļø Debounce time: 2 minutes between watering per pot'); + console.log('================================================================\n'); + + // METHOD 1: Polling (RELIABLE - uses REST API) + // Check sensor threshold every 30 seconds + setInterval(async () => { + try { + await checkSensorThresholds(); + } catch (error) { + console.error('āŒ Polling sensor check failed:', error.message); + } + }, 30000); // 30 seconds + + // Run first check immediately + setTimeout(async () => { + console.log('šŸš€ Running first sensor check...'); + try { + await checkSensorThresholds(); + console.log('āœ… First sensor check completed'); + } catch (error) { + console.error('āŒ First sensor check failed:', error.message); + } + }, 10000); // 10 seconds after startup + + // METHOD 2: Firebase Listener (BACKUP - might not work if SDK fails) + try { + db.ref('data').on('value', async (snapshot) => { + console.log('šŸ”” Firebase listener triggered (SDK working!)'); + // Call the same check function + await checkSensorThresholds(); + }, (error) => { + console.error('āŒ Firebase listener error:', error.message); + }); + console.log('āœ… Firebase listener attached (backup method)'); + } catch (error) { + console.log('āš ļø Firebase listener failed to attach (will rely on polling)'); + } } setupSensorMonitoring();