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:
parent
1224c8e867
commit
6c3b2f3112
|
|
@ -8,6 +8,7 @@ class JadwalModel {
|
|||
final List<int> 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<int>? 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ class ThresholdModel {
|
|||
final List<int> 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<int> 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<int>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -336,6 +336,7 @@ class _DashboardPageState extends State<DashboardPage> {
|
|||
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<DashboardPage> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Connection Status Indicator
|
||||
// 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<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({
|
||||
required String title,
|
||||
required String value,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class _JadwalFormPageState extends State<JadwalFormPage> {
|
|||
late List<bool> _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<JadwalFormPage> {
|
|||
|
||||
_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<JadwalFormPage> {
|
|||
_potSelection = [false, false, false, false, false];
|
||||
_pompaAir = true;
|
||||
_pompaPupuk = false;
|
||||
_pompaPengaduk = false;
|
||||
_aktif = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -150,6 +153,7 @@ class _JadwalFormPageState extends State<JadwalFormPage> {
|
|||
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<JadwalFormPage> {
|
|||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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,22 +385,95 @@ class _JadwalManagementPageState extends State<JadwalManagementPage> {
|
|||
),
|
||||
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<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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ class _KontrolPageState extends State<KontrolPage> {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ThresholdManagementPage(),
|
||||
builder:
|
||||
(context) => const ThresholdManagementPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -156,7 +156,8 @@ class _PotSelectionPageState extends State<PotSelectionPage> {
|
|||
onPressed: () {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ThresholdManagementPage(),
|
||||
builder:
|
||||
(context) => const ThresholdManagementPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
|
|||
late List<bool> _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<ThresholdFormPage> {
|
|||
|
||||
_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<ThresholdFormPage> {
|
|||
_potSelection = [false, false, false, false, false];
|
||||
_pompaAir = true;
|
||||
_pompaPupuk = false;
|
||||
_pompaPengaduk = false;
|
||||
_aktif = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -130,6 +133,7 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
|
|||
potAktif: _selectedPots,
|
||||
pompaAir: _pompaAir,
|
||||
pompaPupuk: _pompaPupuk,
|
||||
pompaPengaduk: _pompaPengaduk,
|
||||
);
|
||||
|
||||
// Save to Firebase
|
||||
|
|
@ -185,7 +189,8 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
|
|||
title: Text(_isEditMode ? 'Edit Threshold' : 'Tambah Threshold'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _isLoading
|
||||
body:
|
||||
_isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Form(
|
||||
key: _formKey,
|
||||
|
|
@ -527,8 +532,25 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
|
|||
_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,12 +589,11 @@ class _ThresholdFormPageState extends State<ThresholdFormPage> {
|
|||
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
|
||||
child:
|
||||
_isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class _ThresholdManagementPageState extends State<ThresholdManagementPage> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_loadThreshold();
|
||||
_loadSensorModeStatus();
|
||||
}
|
||||
|
||||
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 {
|
||||
final newStatus = !threshold.aktif;
|
||||
final success = await ThresholdService.toggleThresholdAktif(
|
||||
|
|
@ -98,7 +116,8 @@ class _ThresholdManagementPageState extends State<ThresholdManagementPage> {
|
|||
Future<bool?> _showConfirmDialog(String title, String message) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
|
|
@ -152,19 +171,79 @@ class _ThresholdManagementPageState extends State<ThresholdManagementPage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
body: Column(
|
||||
children: [
|
||||
_buildSensorModeCard(),
|
||||
Expanded(
|
||||
child:
|
||||
_isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadThreshold,
|
||||
child: _thresholdList.isEmpty
|
||||
: _thresholdList.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildThresholdList(),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _navigateToForm(),
|
||||
backgroundColor: AppColor.primary,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Tambah Threshold'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
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<ThresholdManagementPage> {
|
|||
}
|
||||
|
||||
Widget _buildThresholdList() {
|
||||
return ListView.builder(
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadThreshold,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _thresholdList.length,
|
||||
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<ThresholdManagementPage> {
|
|||
),
|
||||
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<ThresholdManagementPage> {
|
|||
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<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
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _navigateToForm(threshold: threshold),
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text('Edit'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.blue,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColor.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _duplicateThreshold(threshold),
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: const Text('Duplikat'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.orange,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -175,11 +175,14 @@ class ThresholdService {
|
|||
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 ||
|
||||
final rangeOverlap =
|
||||
!(batasAtas < threshold.batasBawah ||
|
||||
batasBawah > threshold.batasAtas);
|
||||
|
||||
if (rangeOverlap) return true;
|
||||
|
|
@ -212,4 +215,27 @@ class ThresholdService {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,10 +171,91 @@ 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)
|
||||
// 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
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
|
|
@ -182,7 +267,7 @@ const wateringWorker = new Worker(
|
|||
await sleep(1000);
|
||||
}
|
||||
|
||||
// Turn OFF
|
||||
// Turn OFF all at once (FIXED mode only)
|
||||
const offUpdates = {};
|
||||
for (const key in updates) {
|
||||
offUpdates[key] = false;
|
||||
|
|
@ -190,14 +275,16 @@ const wateringWorker = new Worker(
|
|||
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,36 +873,44 @@ setTimeout(async () => {
|
|||
|
||||
let sensorCheckCounter = 0;
|
||||
|
||||
async function setupSensorMonitoring() {
|
||||
console.log('✅ Sensor Mode (Threshold System) monitoring started');
|
||||
|
||||
db.ref('data').on('value', async (snapshot) => {
|
||||
// Core sensor check logic (shared by listener and polling)
|
||||
async function checkSensorThresholds() {
|
||||
sensorCheckCounter++;
|
||||
|
||||
try {
|
||||
const sensorData = snapshot.val();
|
||||
// Fetch sensor data using smart method (REST fallback)
|
||||
const sensorData = await readFirebaseSmart('data');
|
||||
|
||||
if (!sensorData) {
|
||||
console.log('⚠️ Sensor data is null/empty');
|
||||
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) {
|
||||
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(`⚠️ 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 (verbose hanya setiap 10 kali)
|
||||
if (sensorCheckCounter % 10 === 0) {
|
||||
// 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) {
|
||||
// No thresholds configured
|
||||
console.log(' ⚠️ No thresholds configured');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -823,8 +918,22 @@ async function setupSensorMonitoring() {
|
|||
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) continue;
|
||||
if (!threshold || !threshold.aktif) {
|
||||
console.log(` ❌ Skipped: ${!threshold ? 'Not found' : 'Not active (aktif=false)'}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const batasBawah = threshold.batas_bawah || 30;
|
||||
const batasAtas = threshold.batas_atas || 70;
|
||||
|
|
@ -834,48 +943,66 @@ async function setupSensorMonitoring() {
|
|||
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;
|
||||
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;
|
||||
|
||||
// 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)`);
|
||||
}
|
||||
// NEW: Check if ABOVE upper threshold - skip if too wet!
|
||||
if (soilValue >= batasAtas) {
|
||||
console.log(` ✅ POT ${potNumber}: SKIP (${soilValue}% >= ${batasAtas}% - sudah basah!)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(` POT ${potNumber}: ${soilValue}% < ${batasBawah}%`);
|
||||
console.log(` Mode: ${smartMode ? 'Smart' : 'Fixed'}, Target: ${smartMode ? batasAtas + '%' : durasi + 's'}`);
|
||||
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}-pot-${potNumber}-${Date.now()}`;
|
||||
const jobId = `${thresholdKey}-${Date.now()}`;
|
||||
await wateringQueue.add(
|
||||
`threshold-pot-${potNumber}`,
|
||||
thresholdKey,
|
||||
{
|
||||
type: 'sensor_threshold',
|
||||
potNumbers: [potNumber],
|
||||
potNumbers: potsNeedWatering, // ALL pots in 1 job!
|
||||
pompaAir: pompaAir,
|
||||
pompaPupuk: pompaPupuk,
|
||||
duration: durasi,
|
||||
scheduleId: jobId,
|
||||
thresholdId: thresholdKey,
|
||||
smartMode: smartMode,
|
||||
sensorData: {
|
||||
soilValue,
|
||||
batasBawah,
|
||||
batasAtas,
|
||||
mode: smartMode ? 'smart' : 'fixed'
|
||||
mode: smartMode ? 'smart' : 'fixed',
|
||||
potValues: potDetails
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -886,16 +1013,59 @@ async function setupSensorMonitoring() {
|
|||
);
|
||||
|
||||
console.log(` 📌 Added to queue: ${jobId}`);
|
||||
|
||||
// Update last watering time to prevent rapid re-triggering
|
||||
lastWateringTime[potKey] = Date.now();
|
||||
}
|
||||
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 threshold monitoring:', error.message);
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue