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'
This commit is contained in:
Wizznu 2026-02-18 08:10:43 +07:00
parent 1224c8e867
commit 6c3b2f3112
12 changed files with 807 additions and 293 deletions

View File

@ -8,6 +8,7 @@ class JadwalModel {
final List<int> potAktif; // Array pot yang aktif [1, 2, 3, 4, 5] final List<int> potAktif; // Array pot yang aktif [1, 2, 3, 4, 5]
final bool pompaAir; final bool pompaAir;
final bool pompaPupuk; final bool pompaPupuk;
final bool pompaPengaduk;
JadwalModel({ JadwalModel({
required this.id, required this.id,
@ -17,6 +18,7 @@ class JadwalModel {
this.potAktif = const [], this.potAktif = const [],
this.pompaAir = true, this.pompaAir = true,
this.pompaPupuk = false, this.pompaPupuk = false,
this.pompaPengaduk = false,
}); });
/// Create from Firebase JSON /// Create from Firebase JSON
@ -29,6 +31,7 @@ class JadwalModel {
potAktif: _parsePotAktif(json['pot_aktif']), potAktif: _parsePotAktif(json['pot_aktif']),
pompaAir: json['pompa_air'] ?? true, pompaAir: json['pompa_air'] ?? true,
pompaPupuk: json['pompa_pupuk'] ?? false, pompaPupuk: json['pompa_pupuk'] ?? false,
pompaPengaduk: json['pompa_pengaduk'] ?? false,
); );
} }
@ -52,6 +55,7 @@ class JadwalModel {
'pot_aktif': potAktif, 'pot_aktif': potAktif,
'pompa_air': pompaAir, 'pompa_air': pompaAir,
'pompa_pupuk': pompaPupuk, 'pompa_pupuk': pompaPupuk,
'pompa_pengaduk': pompaPengaduk,
}; };
} }
@ -64,6 +68,7 @@ class JadwalModel {
List<int>? potAktif, List<int>? potAktif,
bool? pompaAir, bool? pompaAir,
bool? pompaPupuk, bool? pompaPupuk,
bool? pompaPengaduk,
}) { }) {
return JadwalModel( return JadwalModel(
id: id ?? this.id, id: id ?? this.id,
@ -73,6 +78,7 @@ class JadwalModel {
potAktif: potAktif ?? this.potAktif, potAktif: potAktif ?? this.potAktif,
pompaAir: pompaAir ?? this.pompaAir, pompaAir: pompaAir ?? this.pompaAir,
pompaPupuk: pompaPupuk ?? this.pompaPupuk, pompaPupuk: pompaPupuk ?? this.pompaPupuk,
pompaPengaduk: pompaPengaduk ?? this.pompaPengaduk,
); );
} }

View File

@ -8,6 +8,7 @@ class ThresholdModel {
final List<int> potAktif; // [1,2,3] atau [4,5] final List<int> potAktif; // [1,2,3] atau [4,5]
final bool pompaAir; final bool pompaAir;
final bool pompaPupuk; final bool pompaPupuk;
final bool pompaPengaduk;
ThresholdModel({ ThresholdModel({
required this.id, required this.id,
@ -19,6 +20,7 @@ class ThresholdModel {
required this.potAktif, required this.potAktif,
required this.pompaAir, required this.pompaAir,
required this.pompaPupuk, required this.pompaPupuk,
required this.pompaPengaduk,
}); });
// Parse dari Firebase // Parse dari Firebase
@ -26,7 +28,9 @@ class ThresholdModel {
List<int> parsePotAktif(dynamic potData) { List<int> parsePotAktif(dynamic potData) {
if (potData == null) return []; if (potData == null) return [];
if (potData is List) { 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 []; return [];
} }
@ -41,6 +45,7 @@ class ThresholdModel {
potAktif: parsePotAktif(json['pot_aktif']), potAktif: parsePotAktif(json['pot_aktif']),
pompaAir: json['pompa_air'] == true, pompaAir: json['pompa_air'] == true,
pompaPupuk: json['pompa_pupuk'] == true, pompaPupuk: json['pompa_pupuk'] == true,
pompaPengaduk: json['pompa_pengaduk'] == true,
); );
} }
@ -55,6 +60,7 @@ class ThresholdModel {
'pot_aktif': potAktif, 'pot_aktif': potAktif,
'pompa_air': pompaAir, 'pompa_air': pompaAir,
'pompa_pupuk': pompaPupuk, 'pompa_pupuk': pompaPupuk,
'pompa_pengaduk': pompaPengaduk,
}; };
} }
@ -100,6 +106,7 @@ class ThresholdModel {
List<int>? potAktif, List<int>? potAktif,
bool? pompaAir, bool? pompaAir,
bool? pompaPupuk, bool? pompaPupuk,
bool? pompaPengaduk,
}) { }) {
return ThresholdModel( return ThresholdModel(
id: id ?? this.id, id: id ?? this.id,
@ -111,6 +118,7 @@ class ThresholdModel {
potAktif: potAktif ?? this.potAktif, potAktif: potAktif ?? this.potAktif,
pompaAir: pompaAir ?? this.pompaAir, pompaAir: pompaAir ?? this.pompaAir,
pompaPupuk: pompaPupuk ?? this.pompaPupuk, pompaPupuk: pompaPupuk ?? this.pompaPupuk,
pompaPengaduk: pompaPengaduk ?? this.pompaPengaduk,
); );
} }
} }

View File

@ -336,6 +336,7 @@ class _DashboardPageState extends State<DashboardPage> {
final suhu = data['suhu'] ?? '0'; final suhu = data['suhu'] ?? '0';
final kelembapan = data['kelembapan'] ?? '0'; final kelembapan = data['kelembapan'] ?? '0';
final ldr = data['ldr'] ?? '0'; final ldr = data['ldr'] ?? '0';
final waterFlow = data['water_flow'] ?? 0;
final soil1 = data['soil_1'] ?? '0'; final soil1 = data['soil_1'] ?? '0';
final soil2 = data['soil_2'] ?? '0'; final soil2 = data['soil_2'] ?? '0';
final soil3 = data['soil_3'] ?? '0'; final soil3 = data['soil_3'] ?? '0';
@ -347,8 +348,14 @@ class _DashboardPageState extends State<DashboardPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Connection Status Indicator // Connection Status & Water Status Indicators
_buildConnectionStatus(), Row(
children: [
_buildConnectionStatus(),
const SizedBox(width: 12),
_buildWaterFlowStatus(waterFlow),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
// Sensor Cards - Temperature, Humidity, Light // Sensor Cards - Temperature, Humidity, Light
@ -434,6 +441,71 @@ class _DashboardPageState extends State<DashboardPage> {
); );
} }
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({ Widget _buildSensorCard({
required String title, required String title,
required String value, required String value,

View File

@ -24,6 +24,7 @@ class _JadwalFormPageState extends State<JadwalFormPage> {
late List<bool> _potSelection; // [pot1, pot2, pot3, pot4, pot5] late List<bool> _potSelection; // [pot1, pot2, pot3, pot4, pot5]
late bool _pompaAir; late bool _pompaAir;
late bool _pompaPupuk; late bool _pompaPupuk;
late bool _pompaPengaduk;
late bool _aktif; late bool _aktif;
bool _isLoading = false; bool _isLoading = false;
@ -58,6 +59,7 @@ class _JadwalFormPageState extends State<JadwalFormPage> {
_pompaAir = jadwal.pompaAir; _pompaAir = jadwal.pompaAir;
_pompaPupuk = jadwal.pompaPupuk; _pompaPupuk = jadwal.pompaPupuk;
_pompaPengaduk = jadwal.pompaPengaduk;
_aktif = jadwal.aktif; _aktif = jadwal.aktif;
} else { } else {
// Default values for new jadwal // Default values for new jadwal
@ -67,6 +69,7 @@ class _JadwalFormPageState extends State<JadwalFormPage> {
_potSelection = [false, false, false, false, false]; _potSelection = [false, false, false, false, false];
_pompaAir = true; _pompaAir = true;
_pompaPupuk = false; _pompaPupuk = false;
_pompaPengaduk = false;
_aktif = true; _aktif = true;
} }
} }
@ -150,6 +153,7 @@ class _JadwalFormPageState extends State<JadwalFormPage> {
potAktif: _selectedPots, potAktif: _selectedPots,
pompaAir: _pompaAir, pompaAir: _pompaAir,
pompaPupuk: _pompaPupuk, pompaPupuk: _pompaPupuk,
pompaPengaduk: _pompaPengaduk,
); );
// Check if time slot is taken (for new or time changed) // Check if time slot is taken (for new or time changed)
@ -603,14 +607,26 @@ class _JadwalFormPageState extends State<JadwalFormPage> {
SwitchListTile( SwitchListTile(
value: _pompaPupuk, value: _pompaPupuk,
onChanged: (value) => setState(() => _pompaPupuk = value), onChanged: (value) => setState(() => _pompaPupuk = value),
title: const Text('Pompa Pupuk'), title: const Text('Pompa Larutan Nutrisi'),
subtitle: const Text('Mengalirkan pupuk/nutrisi'), subtitle: const Text('Mengalirkan larutan nutrisi cair'),
secondary: Icon( secondary: Icon(
Icons.science, Icons.science,
color: _pompaPupuk ? Colors.orange : Colors.grey, color: _pompaPupuk ? Colors.orange : Colors.grey,
), ),
activeColor: AppColors.primaryGreen, 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,
),
], ],
), ),
); );

View File

@ -165,12 +165,6 @@ class _JadwalManagementPageState extends State<JadwalManagementPage> {
), ),
], ],
), ),
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<JadwalManagementPage> {
onRefresh: _loadJadwal, onRefresh: _loadJadwal,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: _jadwalList.length, itemCount: _jadwalList.length + 1, // +1 for add button
itemBuilder: (context, index) { 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]; final jadwal = _jadwalList[index];
return _buildJadwalCard(jadwal); return _buildJadwalCard(jadwal);
}, },
@ -369,24 +385,97 @@ class _JadwalManagementPageState extends State<JadwalManagementPage> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Pumps // Pumps - Display only if enabled
Row( Wrap(
spacing: 8,
runSpacing: 8,
children: [ children: [
Expanded( if (jadwal.pompaAir)
child: _buildPumpChip( Container(
'Pompa Air', padding: const EdgeInsets.symmetric(
jadwal.pompaAir, horizontal: 8,
Icons.water_drop, 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],
),
),
],
),
), ),
), if (jadwal.pompaPupuk)
const SizedBox(width: 8), Container(
Expanded( padding: const EdgeInsets.symmetric(
child: _buildPumpChip( horizontal: 8,
'Pompa Pupuk', vertical: 4,
jadwal.pompaPupuk, ),
Icons.science, 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<JadwalManagementPage> {
], ],
); );
} }
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,
),
),
],
),
);
}
} }

View File

@ -83,7 +83,8 @@ class _KontrolPageState extends State<KontrolPage> {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const ThresholdManagementPage(), builder:
(context) => const ThresholdManagementPage(),
), ),
); );
}, },

View File

@ -156,7 +156,8 @@ class _PotSelectionPageState extends State<PotSelectionPage> {
onPressed: () { onPressed: () {
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const ThresholdManagementPage(), builder:
(context) => const ThresholdManagementPage(),
), ),
); );
}, },

View File

@ -25,6 +25,7 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
late List<bool> _potSelection; // [pot1, pot2, pot3, pot4, pot5] late List<bool> _potSelection; // [pot1, pot2, pot3, pot4, pot5]
late bool _pompaAir; late bool _pompaAir;
late bool _pompaPupuk; late bool _pompaPupuk;
late bool _pompaPengaduk;
late bool _aktif; late bool _aktif;
bool _isLoading = false; bool _isLoading = false;
@ -54,6 +55,7 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
_pompaAir = threshold.pompaAir; _pompaAir = threshold.pompaAir;
_pompaPupuk = threshold.pompaPupuk; _pompaPupuk = threshold.pompaPupuk;
_pompaPengaduk = threshold.pompaPengaduk;
_aktif = threshold.aktif; _aktif = threshold.aktif;
} else { } else {
// Default values for new threshold // Default values for new threshold
@ -65,6 +67,7 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
_potSelection = [false, false, false, false, false]; _potSelection = [false, false, false, false, false];
_pompaAir = true; _pompaAir = true;
_pompaPupuk = false; _pompaPupuk = false;
_pompaPengaduk = false;
_aktif = true; _aktif = true;
} }
} }
@ -130,6 +133,7 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
potAktif: _selectedPots, potAktif: _selectedPots,
pompaAir: _pompaAir, pompaAir: _pompaAir,
pompaPupuk: _pompaPupuk, pompaPupuk: _pompaPupuk,
pompaPengaduk: _pompaPengaduk,
); );
// Save to Firebase // Save to Firebase
@ -185,27 +189,28 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
title: Text(_isEditMode ? 'Edit Threshold' : 'Tambah Threshold'), title: Text(_isEditMode ? 'Edit Threshold' : 'Tambah Threshold'),
centerTitle: true, centerTitle: true,
), ),
body: _isLoading body:
? const Center(child: CircularProgressIndicator()) _isLoading
: Form( ? const Center(child: CircularProgressIndicator())
key: _formKey, : Form(
child: ListView( key: _formKey,
padding: const EdgeInsets.all(16), child: ListView(
children: [ padding: const EdgeInsets.all(16),
_buildRangeSection(), children: [
const SizedBox(height: 24), _buildRangeSection(),
_buildModeSection(), const SizedBox(height: 24),
const SizedBox(height: 24), _buildModeSection(),
_buildPotSelection(), const SizedBox(height: 24),
const SizedBox(height: 24), _buildPotSelection(),
_buildPumpSection(), const SizedBox(height: 24),
const SizedBox(height: 24), _buildPumpSection(),
_buildStatusSection(), const SizedBox(height: 24),
const SizedBox(height: 32), _buildStatusSection(),
_buildSaveButton(), const SizedBox(height: 32),
], _buildSaveButton(),
],
),
), ),
),
); );
} }
@ -527,8 +532,25 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
_pompaPupuk = value; _pompaPupuk = value;
}); });
}, },
title: const Text('Pompa Pupuk'), title: const Text('Pompa Larutan Nutrisi'),
subtitle: const Text('Aktifkan pompa pupuk saat penyiraman'), 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, activeColor: AppColor.primary,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
@ -567,27 +589,26 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
backgroundColor: AppColor.primary, backgroundColor: AppColor.primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(12),
),
elevation: 2, elevation: 2,
), ),
child: _isLoading child:
? const SizedBox( _isLoading
height: 20, ? const SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator( width: 20,
strokeWidth: 2, child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white), strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(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,
),
),
); );
} }
} }

View File

@ -22,6 +22,7 @@ class _ThresholdManagementPageState extends State<ThresholdManagementPage> {
void initState() { void initState() {
super.initState(); super.initState();
_loadThreshold(); _loadThreshold();
_loadSensorModeStatus();
} }
Future<void> _loadThreshold() async { Future<void> _loadThreshold() async {
@ -38,6 +39,23 @@ class _ThresholdManagementPageState extends State<ThresholdManagementPage> {
} }
} }
Future<void> _loadSensorModeStatus() async {
final status = await ThresholdService.getSensorModeStatus();
setState(() => _sensorModeEnabled = status);
}
Future<void> _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<void> _toggleThresholdAktif(ThresholdModel threshold) async { Future<void> _toggleThresholdAktif(ThresholdModel threshold) async {
final newStatus = !threshold.aktif; final newStatus = !threshold.aktif;
final success = await ThresholdService.toggleThresholdAktif( final success = await ThresholdService.toggleThresholdAktif(
@ -98,20 +116,21 @@ class _ThresholdManagementPageState extends State<ThresholdManagementPage> {
Future<bool?> _showConfirmDialog(String title, String message) { Future<bool?> _showConfirmDialog(String title, String message) {
return showDialog<bool>( return showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder:
title: Text(title), (context) => AlertDialog(
content: Text(message), title: Text(title),
actions: [ content: Text(message),
TextButton( actions: [
onPressed: () => Navigator.pop(context, false), TextButton(
child: const Text('Batal'), 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<ThresholdManagementPage> {
), ),
], ],
), ),
body: _isLoading body: Column(
? const Center(child: CircularProgressIndicator()) children: [
: RefreshIndicator( _buildSensorModeCard(),
onRefresh: _loadThreshold, Expanded(
child: _thresholdList.isEmpty child:
? _buildEmptyState() _isLoading
: _buildThresholdList(), ? 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( child: Icon(
onPressed: () => _navigateToForm(), Icons.sensors,
backgroundColor: AppColor.primary, color: _sensorModeEnabled ? AppColor.primary : Colors.grey,
icon: const Icon(Icons.add), size: 28,
label: const Text('Tambah Threshold'), ),
),
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<ThresholdManagementPage> {
} }
Widget _buildThresholdList() { Widget _buildThresholdList() {
return ListView.builder( return RefreshIndicator(
padding: const EdgeInsets.all(16), onRefresh: _loadThreshold,
itemCount: _thresholdList.length, child: ListView.builder(
itemBuilder: (context, index) { padding: const EdgeInsets.all(16),
final threshold = _thresholdList[index]; itemCount: _thresholdList.length + 1, // +1 for add button
return _buildThresholdCard(threshold); 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<ThresholdManagementPage> {
), ),
child: Icon( child: Icon(
Icons.sensors, Icons.sensors,
color: threshold.aktif ? AppColors.primaryGreen : Colors.grey, color:
threshold.aktif
? AppColors.primaryGreen
: Colors.grey,
size: 24, size: 24,
), ),
), ),
@ -383,7 +490,7 @@ class _ThresholdManagementPageState extends State<ThresholdManagementPage> {
Icon(Icons.eco, size: 14, color: Colors.green[700]), Icon(Icons.eco, size: 14, color: Colors.green[700]),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'Pupuk', 'Larutan Nutrisi',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.green[900], color: Colors.green[900],
@ -392,6 +499,39 @@ class _ThresholdManagementPageState extends State<ThresholdManagementPage> {
], ],
), ),
), ),
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<ThresholdManagementPage> {
// Action buttons // Action buttons
Row( Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
TextButton.icon( Expanded(
onPressed: () => _navigateToForm(threshold: threshold), child: OutlinedButton.icon(
icon: const Icon(Icons.edit, size: 18), onPressed: () => _navigateToForm(threshold: threshold),
label: const Text('Edit'), icon: const Icon(Icons.edit, size: 18),
style: TextButton.styleFrom( label: const Text('Edit'),
foregroundColor: Colors.blue, style: OutlinedButton.styleFrom(
foregroundColor: AppColor.primary,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
TextButton.icon( Expanded(
onPressed: () => _duplicateThreshold(threshold), child: OutlinedButton.icon(
icon: const Icon(Icons.copy, size: 18), onPressed: () => _duplicateThreshold(threshold),
label: const Text('Duplikat'), icon: const Icon(Icons.copy, size: 18),
style: TextButton.styleFrom( label: const Text('Duplikat'),
foregroundColor: Colors.orange, style: OutlinedButton.styleFrom(
foregroundColor: Colors.blue,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
TextButton.icon( IconButton(
onPressed: () => _deleteThreshold(threshold), onPressed: () => _deleteThreshold(threshold),
icon: const Icon(Icons.delete, size: 18), icon: const Icon(Icons.delete),
label: const Text('Hapus'), color: Colors.red,
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
), ),
], ],
), ),

View File

@ -19,6 +19,7 @@ class FirebaseDatabaseService {
'suhu': _parseValue(data['suhu']), 'suhu': _parseValue(data['suhu']),
'kelembapan': _parseValue(data['kelembapan']), 'kelembapan': _parseValue(data['kelembapan']),
'ldr': _parseValue(data['ldr']), 'ldr': _parseValue(data['ldr']),
'water_flow': data['water_flow'] ?? 0, // TAMBAHAN: water flow sensor
'soil_1': _parseValue(data['soil_1']), 'soil_1': _parseValue(data['soil_1']),
'soil_2': _parseValue(data['soil_2']), 'soil_2': _parseValue(data['soil_2']),
'soil_3': _parseValue(data['soil_3']), 'soil_3': _parseValue(data['soil_3']),
@ -40,6 +41,7 @@ class FirebaseDatabaseService {
'suhu': _parseValue(data['suhu']), 'suhu': _parseValue(data['suhu']),
'kelembapan': _parseValue(data['kelembapan']), 'kelembapan': _parseValue(data['kelembapan']),
'ldr': _parseValue(data['ldr']), 'ldr': _parseValue(data['ldr']),
'water_flow': data['water_flow'] ?? 0, // TAMBAHAN: water flow sensor
'soil_1': _parseValue(data['soil_1']), 'soil_1': _parseValue(data['soil_1']),
'soil_2': _parseValue(data['soil_2']), 'soil_2': _parseValue(data['soil_2']),
'soil_3': _parseValue(data['soil_3']), 'soil_3': _parseValue(data['soil_3']),

View File

@ -175,12 +175,15 @@ class ThresholdService {
if (threshold.id == currentId) continue; if (threshold.id == currentId) continue;
// Cek apakah ada pot yang sama // 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; if (!hasSamePot) continue;
// Cek apakah range overlap // Cek apakah range overlap
final rangeOverlap = !(batasAtas < threshold.batasBawah || final rangeOverlap =
batasBawah > threshold.batasAtas); !(batasAtas < threshold.batasBawah ||
batasBawah > threshold.batasAtas);
if (rangeOverlap) return true; if (rangeOverlap) return true;
} }
@ -212,4 +215,27 @@ class ThresholdService {
return 'threshold_1'; return 'threshold_1';
} }
} }
// Get sensor mode status (otomatis)
static Future<bool> 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<bool> 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;
}
}
} }

View File

@ -133,20 +133,24 @@ const wateringQueue = new Queue('watering', { connection: redis });
redis.on('connect', () => console.log('✅ Redis connected')); redis.on('connect', () => console.log('✅ Redis connected'));
redis.on('error', (err) => console.error('❌ Redis error:', err.message)); redis.on('error', (err) => console.error('❌ Redis error:', err.message));
// Track last watering time untuk prevent spam // Track last watering time PER-THRESHOLD (not per-pot!) untuk prevent spam
const lastWateringTime = {}; const lastThresholdTime = {};
// ==================== WATERING WORKER ==================== // ==================== WATERING WORKER ====================
const wateringWorker = new Worker( const wateringWorker = new Worker(
'watering', 'watering',
async (job) => { 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(`\n💧 Processing Job: ${job.id}`);
console.log(` Type: ${type}`); console.log(` Type: ${type}`);
console.log(` Pots: [${potNumbers.join(', ')}]`); 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 { try {
// Prepare aktuator updates // Prepare aktuator updates
@ -167,37 +171,120 @@ const wateringWorker = new Worker(
console.log(' 📝 Updates:', JSON.stringify(updates, null, 2)); console.log(' 📝 Updates:', JSON.stringify(updates, null, 2));
await updateFirebaseSmart('aktuator', updates); 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) // 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();
// Wait for duration with progress logging // Track which pots are still actively watering
const startTime = Date.now(); let activePots = [...potNumbers];
const endTime = startTime + duration * 1000;
while (Date.now() < endTime) { console.log(` 🎯 SMART MODE: Monitoring ${activePots.length} pots, target ${targetSoil}%...`);
const remaining = Math.ceil((endTime - Date.now()) / 1000); console.log(` ⚡ Valves will stop TOGETHER when pots reach target (checked every 2s)`);
if (remaining % 10 === 0 || remaining <= 5) {
console.log(`${remaining}s remaining...`); 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);
}
// Turn OFF // If any pots still active after max duration (timeout), stop them now TOGETHER
const offUpdates = {}; if (activePots.length > 0) {
for (const key in updates) { console.log(` ⏱️ Max duration ${duration}s reached. Force stopping remaining pots: [${activePots.join(', ')}]`);
offUpdates[key] = false; 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;
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 // Log history
await logHistory(type, potNumbers, duration); await logHistory(type, potNumbers, duration);
console.log(' ✅ History logged successfully'); console.log(' ✅ History logged successfully');
// Update last watering time // Update last watering time PER-THRESHOLD (not per-pot!)
for (const pot of potNumbers) { if (thresholdId) {
lastWateringTime[`pot_${pot}`] = Date.now(); lastThresholdTime[thresholdId] = Date.now();
console.log(` ⏰ Cooldown set for ${thresholdId} (2 minutes)`);
} }
console.log(` ✅ Job completed successfully`); console.log(` ✅ Job completed successfully`);
@ -786,116 +873,199 @@ setTimeout(async () => {
let sensorCheckCounter = 0; let sensorCheckCounter = 0;
async function setupSensorMonitoring() { // Core sensor check logic (shared by listener and polling)
console.log('✅ Sensor Mode (Threshold System) monitoring started'); async function checkSensorThresholds() {
sensorCheckCounter++;
db.ref('data').on('value', async (snapshot) => { try {
sensorCheckCounter++; // Fetch sensor data using smart method (REST fallback)
const sensorData = await readFirebaseSmart('data');
try { if (!sensorData) {
const sensorData = snapshot.val(); console.log('⚠️ Sensor data is null/empty - ESP32 might not be sending data');
if (!sensorData) { return;
console.log('⚠️ Sensor data is null/empty'); }
return;
}
const configSnapshot = await fetchWithTimeout(db.ref(FIREBASE_PATHS.kontrol), 10000); // Fetch kontrol config using smart method
const kontrolConfig = configSnapshot.val(); const kontrolConfig = await fetchKontrolSmart();
if (!kontrolConfig) { if (!kontrolConfig) {
return; console.log('⚠️ Kontrol config is null');
} return;
}
// Detect all threshold_* nodes // Check if sensor mode is enabled (using 'otomatis' field, not deprecated 'sensor')
const allThresholds = Object.keys(kontrolConfig).filter(key => key.startsWith('threshold_')); if (!kontrolConfig.otomatis) {
// Log sensor check (verbose hanya setiap 10 kali)
if (sensorCheckCounter % 10 === 0) { 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) { // Check THRESHOLD cooldown (not per-pot!)
// No thresholds configured const lastTime = lastThresholdTime[thresholdKey];
return; 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 const batasBawah = threshold.batas_bawah || 30;
for (const thresholdKey of allThresholds) { const batasAtas = threshold.batas_atas || 70;
const threshold = kontrolConfig[thresholdKey]; 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;
// Skip if threshold is not active or invalid // Collect pots that need watering in this threshold
if (!threshold || !threshold.aktif) continue; const potsNeedWatering = [];
const potDetails = [];
const batasBawah = threshold.batas_bawah || 30; // Check each pot in this threshold
const batasAtas = threshold.batas_atas || 70; for (const potNumber of potAktif) {
const durasi = threshold.durasi || 600; if (potNumber < 1 || potNumber > 5) {
const smartMode = threshold.smart_mode === true; console.log(` ⚠️ POT ${potNumber}: Invalid pot number (must be 1-5)`);
const potAktif = threshold.pot_aktif || []; continue;
const pompaAir = threshold.pompa_air === true; }
const pompaPupuk = threshold.pompa_pupuk === true;
// Check each pot in this threshold const soilKey = `soil_${potNumber}`;
for (const potNumber of potAktif) { const soilValue = parseInt(sensorData[soilKey]) || 0;
if (potNumber < 1 || potNumber > 5) continue;
const soilKey = `soil_${potNumber}`; console.log(` 🌱 POT ${potNumber} (${soilKey}): ${soilValue}% | Threshold: ${batasBawah}-${batasAtas}%`);
const soilValue = parseInt(sensorData[soilKey]) || 0; console.log(` → Raw value: ${sensorData[soilKey]} | Parsed: ${soilValue} | Check: ${soilValue} < ${batasBawah} = ${soilValue < batasBawah}`);
// Check if below threshold // NEW: Check if ABOVE upper threshold - skip if too wet!
if (soilValue < batasBawah) { if (soilValue >= batasAtas) {
const potKey = `pot_${potNumber}`; console.log(` ✅ POT ${potNumber}: SKIP (${soilValue}% >= ${batasAtas}% - sudah basah!)`);
const lastTime = lastWateringTime[potKey]; continue;
}
// Debounce: minimum 2 menit antar penyiraman // Check if below lower threshold
if (lastTime && Date.now() - lastTime < config.worker.sensorDebounce) { if (soilValue < batasBawah) {
const remainingSeconds = Math.ceil((config.worker.sensorDebounce - (Date.now() - lastTime)) / 1000); console.log(` 🚨 POT ${potNumber} KERING! ${soilValue}% < ${batasBawah}%`);
if (sensorCheckCounter % 10 === 0) {
console.log(`${thresholdKey.toUpperCase()} - POT ${potNumber}: Cooldown (${remainingSeconds}s)`);
}
continue;
}
console.log(`\n🌡️ THRESHOLD TRIGGERED: ${thresholdKey.toUpperCase()}`); // Add to watering list (cooldown already checked at threshold level)
console.log(` POT ${potNumber}: ${soilValue}% < ${batasBawah}%`); potsNeedWatering.push(potNumber);
console.log(` Mode: ${smartMode ? 'Smart' : 'Fixed'}, Target: ${smartMode ? batasAtas + '%' : durasi + 's'}`); potDetails.push({ pot: potNumber, value: soilValue });
console.log(` Pumps: Air=${pompaAir}, Pupuk=${pompaPupuk}`); } else {
console.log(` ✅ POT ${potNumber}: OK (${soilValue}% >= ${batasBawah}%)`);
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();
}
} }
} }
} 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(); setupSensorMonitoring();