tambahan perhitungan

This commit is contained in:
Mochamad ongki ramadani 2026-06-01 17:06:29 +07:00
parent fcef9d7e5d
commit 4f9182e4b4
7 changed files with 449 additions and 21 deletions

View File

@ -22,6 +22,8 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
on<EvaporasiKoreksiOffsetChanged>(_onOffsetChanged);
on<EvaporasiPumpStartChanged>(_onPumpStartChanged);
on<EvaporasiPumpEndChanged>(_onPumpEndChanged);
on<EvaporasiStandarTinggiChanged>(_onStandarTinggiChanged);
on<EvaporasiBatasKritisChanged>(_onBatasKritisChanged);
on<EvaporasiD0Changed>(_onD0Changed);
on<EvaporasiDmaxManualChanged>(_onDmaxManualChanged);
on<EvaporasiIntervalRealtimeChanged>(_onIntervalRealtimeChanged);
@ -44,10 +46,16 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
thresholdRendah: _toDouble(data['threshold_rendah'], 2.0),
thresholdTinggi: _toDouble(data['threshold_tinggi'], 10.0),
rumusKalibrasi: (data['rumus_kalibrasi'] as String?) ?? 'selisih_max',
koreksiOffset: _toDouble(data['koreksi_offset'], 0.0), pumpStartTime: (data['pump_start_time'] as String?) ?? '06:00',
pumpEndTime: (data['pump_end_time'] as String?) ?? '18:00',
koreksiOffset: _toDouble(data['koreksi_offset'], 0.0),
pumpStartTime: (data['pump_start_time'] as String?) ??
(data['jam_pompa_mulai'] != null ? '${_toInt(data['jam_pompa_mulai'], 6).toString().padLeft(2, '0')}:00' : '06:00'),
pumpEndTime: (data['pump_end_time'] as String?) ??
(data['jam_pompa_selesai'] != null ? '${_toInt(data['jam_pompa_selesai'], 18).toString().padLeft(2, '0')}:00' : '18:00'),
d0: _toInt(data['d0'], 0),
dmaxManual: _toInt(data['dmax_manual'], 0), intervalRealtime_ms: _toInt(data['interval_realtime_ms'], 300000),
dmaxManual: _toInt(data['dmax_manual'], 0),
standarTinggiCm: _toDouble(data['standar_tinggi_cm'] ?? data['standar_tinggi'], 18.0),
batasKritisCm: _toDouble(data['batas_kritis_cm'] ?? data['batas_kritis'], 15.0),
intervalRealtime_ms: _toInt(data['interval_realtime_ms'], 300000),
intervalHistory_ms: _toInt(data['interval_history_ms'], 600000),
intervalBaca_ms: _toInt(data['interval_baca_ms'], 10000),
status: EvaporasiSettingsStatus.loaded,
@ -91,6 +99,19 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
activeD0: activeD0,
activeDmax: activeDmax,
lastUpdate: lastUpd,
sensorError: _toBool(rt['sensor_error'], state.sensorError),
ntpSync: _toBool(rt['ntp_sync'], state.ntpSync),
snapshotCm: _toDouble(rt['snapshot_cm'] ?? rt['snapshot'], state.snapshotCm),
standarTinggiCm: _toDouble(rt['standar_tinggi'] ?? rt['standar_tinggi_cm'], state.standarTinggiCm),
batasKritisCm: _toDouble(rt['batas_kritis'] ?? rt['batas_kritis_cm'], state.batasKritisCm),
tempCompActive: _toBool(rt['temp_comp_aktif'], state.tempCompActive),
tempCompCoef: _toDouble(rt['temp_comp_koef'] ?? rt['tempCompKoef'] ?? 0.0, state.tempCompCoef),
tempRefC: _toDouble(rt['temp_ref_c'] ?? rt['temp_ref'] ?? 0.0, state.tempRefC),
otaTrigger: _toBool(rt['ota_trigger'], state.otaTrigger),
relayAktif: _toBool(rt['selenoid'], state.relayAktif),
historyCount: _toInt(rt['history_count'], state.historyCount),
lastRealtime: _parseDateTime(rt['datetime']) ?? lastUpd,
otaStatus: (rt['ota_status'] as String?) ?? state.otaStatus,
));
} else {
// fallback: try single path
@ -148,6 +169,16 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(pumpEndTime: event.value));
void _onStandarTinggiChanged(
EvaporasiStandarTinggiChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(standarTinggiCm: event.value));
void _onBatasKritisChanged(
EvaporasiBatasKritisChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(batasKritisCm: event.value));
void _onD0Changed(
EvaporasiD0Changed event,
Emitter<EvaporasiSettingsState> emit,
@ -188,6 +219,8 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
emit(state.copyWith(status: EvaporasiSettingsStatus.saving));
try {
final pumpStartHour = int.tryParse(state.pumpStartTime.split(':').first) ?? 0;
final pumpEndHour = int.tryParse(state.pumpEndTime.split(':').first) ?? 0;
await _ref.set({
'threshold_rendah': state.thresholdRendah,
'threshold_tinggi': state.thresholdTinggi,
@ -195,8 +228,12 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
'koreksi_offset': state.koreksiOffset,
'pump_start_time': state.pumpStartTime,
'pump_end_time': state.pumpEndTime,
'jam_pompa_mulai': pumpStartHour,
'jam_pompa_selesai': pumpEndHour,
'd0': state.d0,
'dmax_manual': state.dmaxManual,
'standar_tinggi_cm': state.standarTinggiCm,
'batas_kritis_cm': state.batasKritisCm,
'interval_realtime_ms': state.intervalRealtime_ms,
'interval_history_ms': state.intervalHistory_ms,
'interval_baca_ms': state.intervalBaca_ms,
@ -213,7 +250,43 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
}
}
double _toDouble(dynamic v, double def) => v == null ? def : (v as num).toDouble();
double _toDouble(dynamic v, double def) {
if (v == null) return def;
if (v is num) return v.toDouble();
if (v is String) {
return double.tryParse(v) ?? def;
}
return def;
}
int _toInt(dynamic v, int def) => v == null ? def : (v as num).toInt();
int _toInt(dynamic v, int def) {
if (v == null) return def;
if (v is num) return v.toInt();
if (v is String) {
return int.tryParse(v) ?? def;
}
return def;
}
bool _toBool(dynamic v, bool def) {
if (v == null) return def;
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) {
final lower = v.toLowerCase().trim();
return lower == 'true' || lower == '1' || lower == 'yes' || lower == 'aktif';
}
return def;
}
DateTime? _parseDateTime(dynamic raw) {
if (raw == null) return null;
if (raw is int) return DateTime.fromMillisecondsSinceEpoch(raw).toLocal();
if (raw is num) return DateTime.fromMillisecondsSinceEpoch(raw.toInt()).toLocal();
if (raw is String) {
final normalized = raw.replaceAll(' ', 'T');
return DateTime.tryParse(normalized)?.toLocal();
}
return null;
}
}

View File

@ -63,6 +63,18 @@ class EvaporasiPumpEndChanged extends EvaporasiSettingsEvent {
@override List<Object?> get props => [value];
}
class EvaporasiStandarTinggiChanged extends EvaporasiSettingsEvent {
final double value;
const EvaporasiStandarTinggiChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiBatasKritisChanged extends EvaporasiSettingsEvent {
final double value;
const EvaporasiBatasKritisChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiD0Changed extends EvaporasiSettingsEvent {
final int value;
const EvaporasiD0Changed(this.value);

View File

@ -19,6 +19,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
final _pumpEndController = TextEditingController();
final _d0Controller = TextEditingController();
final _dmaxManualController = TextEditingController();
final _standarController = TextEditingController();
final _batasController = TextEditingController();
void _syncControllers(EvaporasiSettingsState s) {
final rendah = s.thresholdRendah.toStringAsFixed(1);
@ -28,6 +30,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
final pumpEnd = s.pumpEndTime;
final d0 = s.d0 == 0 ? '' : s.d0.toString();
final dmaxManual = s.dmaxManual == 0 ? '' : s.dmaxManual.toString();
final standar = s.standarTinggiCm.toStringAsFixed(1);
final batas = s.batasKritisCm.toStringAsFixed(1);
if (_rendahController.text != rendah) {
_rendahController.text = rendah;
@ -50,6 +54,12 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
if (_dmaxManualController.text != dmaxManual) {
_dmaxManualController.text = dmaxManual;
}
if (_standarController.text != standar) {
_standarController.text = standar;
}
if (_batasController.text != batas) {
_batasController.text = batas;
}
}
Future<void> _pickPumpTime(BuildContext context, TextEditingController controller, ValueChanged<String> onSelected, String initialValue) async {
@ -79,6 +89,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
_pumpEndController.dispose();
_d0Controller.dispose();
_dmaxManualController.dispose();
_standarController.dispose();
_batasController.dispose();
super.dispose();
}
@ -142,6 +154,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
'langsung berlaku untuk semua perangkat.',
),
const SizedBox(height: 24),
_groupTitle('Grup A — Sistem & Kalibrasi'),
const SizedBox(height: 12),
_sectionTitle('Batas Status Evaporasi'),
const SizedBox(height: 6),
Text(
@ -259,6 +273,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
),
]),
const SizedBox(height: 24),
_groupTitle('Grup B — Aktuator & Pengaturan'),
const SizedBox(height: 12),
_sectionTitle('Kontrol Pompa'),
const SizedBox(height: 6),
Text(
@ -325,6 +341,32 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
}
},
),
const SizedBox(height: 16),
_NumericField(
controller: _standarController,
label: 'Standar Tinggi Air (cm)',
hint: 'Contoh: 18.0',
helper: 'Nilai tinggi standar untuk selenoid (cm).',
onChanged: (v) {
final d = double.tryParse(v);
if (d != null && d >= 0) {
bloc.add(EvaporasiStandarTinggiChanged(d));
}
},
),
const SizedBox(height: 16),
_NumericField(
controller: _batasController,
label: 'Batas Kritis (cm)',
hint: 'Contoh: 15.0',
helper: 'Jika di bawah nilai ini → pompa ON paksa.',
onChanged: (v) {
final d = double.tryParse(v);
if (d != null && d >= 0) {
bloc.add(EvaporasiBatasKritisChanged(d));
}
},
),
]),
const SizedBox(height: 24),
SizedBox(
@ -358,6 +400,11 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
);
}
Widget _groupTitle(String text) => Text(
text,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.blueGrey),
);
Widget _sectionTitle(String text) => Text(
text,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),

View File

@ -25,6 +25,20 @@ class EvaporasiSettingsState extends Equatable {
final int intervalHistory_ms; // default 600000 = 10 menit
final int intervalBaca_ms; // default 10000 = 10 detik
final double standarTinggiCm;
final double batasKritisCm;
final bool tempCompActive;
final double tempCompCoef;
final double tempRefC;
final bool sensorError;
final bool ntpSync;
final double snapshotCm;
final bool otaTrigger;
final bool relayAktif;
final String otaStatus;
final int historyCount;
final DateTime? lastRealtime;
final EvaporasiSettingsStatus status;
final String? errorMessage;
@ -40,6 +54,19 @@ class EvaporasiSettingsState extends Equatable {
this.intervalRealtime_ms = 300000,
this.intervalHistory_ms = 600000,
this.intervalBaca_ms = 10000,
this.standarTinggiCm = 18.0,
this.batasKritisCm = 15.0,
this.tempCompActive = true,
this.tempCompCoef = 500.0,
this.tempRefC = 25.0,
this.sensorError = false,
this.ntpSync = false,
this.snapshotCm = 0.0,
this.otaTrigger = false,
this.relayAktif = false,
this.otaStatus = '--',
this.historyCount = 0,
this.lastRealtime,
this.status = EvaporasiSettingsStatus.loading,
this.errorMessage,
this.dmax = 0,
@ -64,6 +91,19 @@ class EvaporasiSettingsState extends Equatable {
int? intervalRealtime_ms,
int? intervalHistory_ms,
int? intervalBaca_ms,
double? standarTinggiCm,
double? batasKritisCm,
bool? tempCompActive,
double? tempCompCoef,
double? tempRefC,
bool? sensorError,
bool? ntpSync,
double? snapshotCm,
bool? otaTrigger,
bool? relayAktif,
String? otaStatus,
int? historyCount,
DateTime? lastRealtime,
EvaporasiSettingsStatus? status,
String? errorMessage,
int? dmax,
@ -84,6 +124,19 @@ class EvaporasiSettingsState extends Equatable {
pumpEndTime: pumpEndTime ?? this.pumpEndTime,
d0: d0 ?? this.d0,
dmaxManual: dmaxManual ?? this.dmaxManual,
standarTinggiCm: standarTinggiCm ?? this.standarTinggiCm,
batasKritisCm: batasKritisCm ?? this.batasKritisCm,
tempCompActive: tempCompActive ?? this.tempCompActive,
tempCompCoef: tempCompCoef ?? this.tempCompCoef,
tempRefC: tempRefC ?? this.tempRefC,
sensorError: sensorError ?? this.sensorError,
ntpSync: ntpSync ?? this.ntpSync,
snapshotCm: snapshotCm ?? this.snapshotCm,
otaTrigger: otaTrigger ?? this.otaTrigger,
relayAktif: relayAktif ?? this.relayAktif,
otaStatus: otaStatus ?? this.otaStatus,
historyCount: historyCount ?? this.historyCount,
lastRealtime: lastRealtime ?? this.lastRealtime,
firmwareVersion: firmwareVersion ?? this.firmwareVersion,
wifiConnected: wifiConnected ?? this.wifiConnected,
firebaseConnected: firebaseConnected ?? this.firebaseConnected,
@ -104,8 +157,10 @@ class EvaporasiSettingsState extends Equatable {
List<Object?> get props => [
thresholdRendah, thresholdTinggi, rumusKalibrasi, koreksiOffset,
pumpStartTime, pumpEndTime, d0, dmaxManual,
firmwareVersion, wifiConnected, firebaseConnected, activeD0, activeDmax, lastUpdate,
intervalRealtime_ms, intervalHistory_ms, intervalBaca_ms,
standarTinggiCm, batasKritisCm, tempCompActive, tempCompCoef, tempRefC,
sensorError, ntpSync, snapshotCm, otaTrigger, relayAktif, historyCount, lastRealtime,
firmwareVersion, wifiConnected, firebaseConnected, activeD0, activeDmax, lastUpdate,
status, errorMessage,
dmax, isResettingDmax,
];

View File

@ -599,6 +599,66 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
);
}
Widget _detailCard(
double width,
String title,
String value,
IconData icon,
Color color, {
String? subtitle,
}) {
return Container(
width: width,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
],
),
const SizedBox(height: 12),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
),
),
],
],
),
);
}
Widget _statusCard(EvaporasiState state) {
Color statusColor;
IconData statusIcon;

View File

@ -14,6 +14,7 @@ class _EvaporasiControlPanelState extends State<EvaporasiControlPanel> {
bool _selenoid = false;
bool _isTogglingSelenoid = false;
bool _isResettingEvaporasi = false;
bool _isTriggeringOta = false;
StreamSubscription<DatabaseEvent>? _subscription;
@override
@ -127,6 +128,42 @@ class _EvaporasiControlPanelState extends State<EvaporasiControlPanel> {
}
}
Future<void> _triggerOTA() async {
if (_isTriggeringOta) return;
setState(() {
_isTriggeringOta = true;
});
try {
await Future.wait([
FirebaseDatabase.instance.ref('Monitoring/ota_trigger').set(true),
FirebaseDatabase.instance
.ref('Monitoring/realtime/ota_trigger')
.set(true),
]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Perintah OTA trigger berhasil dikirim.'),
behavior: SnackBarBehavior.floating,
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Gagal mengirim perintah OTA trigger: $e'),
backgroundColor: Colors.red.shade600,
behavior: SnackBarBehavior.floating,
));
}
} finally {
if (mounted) {
setState(() {
_isTriggeringOta = false;
});
}
}
}
@override
void dispose() {
_subscription?.cancel();
@ -160,7 +197,7 @@ class _EvaporasiControlPanelState extends State<EvaporasiControlPanel> {
),
const SizedBox(height: 12),
Text(
'Tombol ini terhubung ke path Firebase yang belum ada di UI utama: reset evaporasi dan kontrol selenoid.',
'Tombol ini terhubung ke Firebase untuk reset evaporasi, kontrol selenoid, dan trigger OTA.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
const SizedBox(height: 16),
@ -213,6 +250,24 @@ class _EvaporasiControlPanelState extends State<EvaporasiControlPanel> {
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
ElevatedButton.icon(
onPressed: _isTriggeringOta ? null : _triggerOTA,
icon: _isTriggeringOta
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.cloud_upload_rounded),
label: Text(_isTriggeringOta ? 'Memanggil OTA...' : 'Trigger OTA'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
],
),
const SizedBox(height: 16),

View File

@ -7,6 +7,20 @@ class Evaporasi {
final double acuanPagi;
final String status;
final DateTime timestamp;
final bool sensorError;
final bool ntpSync;
final double snapshotCm;
final double standarTinggi;
final double batasKritis;
final int jamPompaMulai;
final int jamPompaSelesai;
final bool tempCompActive;
final double tempCompCoef;
final double tempRefC;
final String otaVersion;
final String otaStatus;
final bool otaTrigger;
final bool relayAktif;
Evaporasi({
required this.evaporasi,
@ -15,6 +29,20 @@ class Evaporasi {
required this.acuanPagi,
required this.status,
required this.timestamp,
this.sensorError = false,
this.ntpSync = false,
this.snapshotCm = 0.0,
this.standarTinggi = 0.0,
this.batasKritis = 0.0,
this.jamPompaMulai = 0,
this.jamPompaSelesai = 0,
this.tempCompActive = false,
this.tempCompCoef = 0.0,
this.tempRefC = 0.0,
this.otaVersion = '-',
this.otaStatus = '-',
this.otaTrigger = false,
this.relayAktif = false,
});
static final empty = Evaporasi(
@ -41,6 +69,30 @@ class Evaporasi {
return 0.0;
}
bool toBoolSafe(dynamic v) {
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) {
final lower = v.trim().toLowerCase();
return lower == 'true' || lower == '1' || lower == 'yes' || lower == 'aktif';
}
return false;
}
int toIntSafe(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) {
return int.tryParse(v.trim()) ?? 0;
}
return 0;
}
String toStringSafe(dynamic v) {
if (v == null) return '-';
return v.toString();
}
// Evaporasi (mm)
final evaporasiRaw = toDoubleSafe(
json['evaporasi_mm'] ??
@ -51,13 +103,11 @@ class Evaporasi {
json['evaporasi_value'] ??
json['evaporasi_k'],
);
// Sudah dalam mm, tidak perlu konversi
final evaporasiVal = evaporasiRaw;
// Suhu (°C)
// FIX: tambah 'suhu_air_c' sesuai field yang dikirim ESP32
final suhuRaw = toDoubleSafe(
json['suhu_air_c'] ?? // ESP32 kirim field ini
json['suhu_air_c'] ??
json['suhu_air'] ??
json['suhu'] ??
json['suhuAir'] ??
@ -77,7 +127,6 @@ class Evaporasi {
json['tinggi_air_m'] ??
json['tinggiAir_m'],
);
// Sudah dalam cm, tidak perlu konversi
final tinggiVal = tinggiRaw;
// Sanity check
@ -92,9 +141,79 @@ class Evaporasi {
// Status
final statusVal = json['status']?.toString() ?? "Normal";
// Parse Timestamp
// FIX: Tambahkan offset +07:00 (WIB) jika string tidak punya info timezone,
// agar tidak terjadi mismatch 7 jam antara Firebase dan Flutter.
final sensorErrorVal = toBoolSafe(
json['sensor_error'] ??
json['sensorError'] ??
json['sensor'] ??
json['status_sensor'],
);
final ntpSyncVal = toBoolSafe(
json['ntp_sync'] ??
json['ntpSync'] ??
json['ntp'],
);
final snapshotVal = toDoubleSafe(
json['snapshot_cm'] ??
json['snapshot'] ??
json['acuan_pagi_cm'] ??
0.0,
);
final standarTinggiVal = toDoubleSafe(
json['standar_tinggi'] ??
json['standarTinggi'] ??
json['standard_height'] ??
0.0,
);
final batasKritisVal = toDoubleSafe(
json['batas_kritis'] ??
json['batasKritis'] ??
json['critical_level'] ??
0.0,
);
final jamPompaMulaiVal = toIntSafe(
json['jam_pompa_mulai'] ??
json['jamPompaMulai'] ??
json['pump_start'] ??
0,
);
final jamPompaSelesaiVal = toIntSafe(
json['jam_pompa_selesai'] ??
json['jamPompaSelesai'] ??
json['pump_end'] ??
0,
);
final tempCompActiveVal = toBoolSafe(
json['temp_comp_aktif'] ??
json['temp_comp_aktif'] ??
json['tempCompAktif'] ??
json['temp_comp_active'] ??
false,
);
final tempCompCoefVal = toDoubleSafe(
json['temp_comp_koef'] ??
json['tempCompKoef'] ??
json['temp_comp_coefficient'] ??
0.0,
);
final tempRefCVal = toDoubleSafe(
json['temp_ref_c'] ??
json['tempRefC'] ??
json['temp_ref'] ??
0.0,
);
final otaVersionVal = toStringSafe(
json['ota_version'] ?? json['otaVersion'] ?? json['firmware_version'] ?? '-',
);
final otaStatusVal = toStringSafe(
json['ota_status'] ?? json['otaStatus'] ?? '-',
);
final otaTriggerVal = toBoolSafe(
json['ota_trigger'] ?? json['otaTrigger'] ?? false,
);
final relayAktifVal = toBoolSafe(
json['selenoid'] ?? json['selenoid_on'] ?? json['relay'],
);
DateTime parseTimestamp(dynamic rawTimestamp) {
try {
if (rawTimestamp is int) {
@ -117,7 +236,6 @@ class Evaporasi {
if (rawTimestamp is String) {
String s = rawTimestamp.trim();
// UNIX string
final unixValue = int.tryParse(s);
if (unixValue != null) {
if (unixValue < 1000000000000) {
@ -128,13 +246,10 @@ class Evaporasi {
return DateTime.fromMillisecondsSinceEpoch(unixValue).toLocal();
}
// Format "YYYY-MM-DD HH:mm:ss" tambah 'T' agar bisa diparsing
if (s.contains(' ') && !s.contains('T')) {
s = s.replaceFirst(' ', 'T');
}
// FIX: Jika tidak ada info timezone, anggap WIB (UTC+7)
// agar jam di chart tidak mismatch 7 jam
if (!s.contains('+') && !s.contains('Z') && !s.contains('-', 10)) {
s = '${s}+07:00';
}
@ -143,18 +258,15 @@ class Evaporasi {
if (parsed != null) return parsed.toLocal();
}
} catch (_) {}
return DateTime.fromMillisecondsSinceEpoch(0);
}
DateTime timestamp = DateTime.fromMillisecondsSinceEpoch(0);
final rawTimestamp = json['timestamp'] ?? json['time'] ?? json['datetime'];
if (rawTimestamp != null) {
timestamp = parseTimestamp(rawTimestamp);
} else {
// Legacy fallback: field "waktu" format "HH:mm:ss"
final waktuStr = json['waktu'] as String?;
if (waktuStr != null) {
final parts = waktuStr.split(':');
@ -175,6 +287,20 @@ class Evaporasi {
acuanPagi: acuanPagiVal,
status: statusVal,
timestamp: timestamp,
sensorError: sensorErrorVal,
ntpSync: ntpSyncVal,
snapshotCm: snapshotVal,
standarTinggi: standarTinggiVal,
batasKritis: batasKritisVal,
jamPompaMulai: jamPompaMulaiVal,
jamPompaSelesai: jamPompaSelesaiVal,
tempCompActive: tempCompActiveVal,
tempCompCoef: tempCompCoefVal,
tempRefC: tempRefCVal,
otaVersion: otaVersionVal,
otaStatus: otaStatusVal,
otaTrigger: otaTriggerVal,
relayAktif: relayAktifVal,
);
}
}