import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; /// URL dasar Node-RED (HTTP In). const String kNodeRedBaseUrl = 'http://node-red.aquamonn.online'; /// Parse timestamp ke epoch ms (mendukung int, string ISO, atau detik). int parseHistoryTimestampMs(dynamic v) { if (v == null) return 0; if (v is int) return v; if (v is num) return v.toInt(); if (v is String) { final trimmed = v.trim(); final asInt = int.tryParse(trimmed); if (asInt != null) { if (asInt > 0 && asInt < 2000000000) return asInt * 1000; return asInt; } final dt = DateTime.tryParse(trimmed); if (dt != null) { if (trimmed.endsWith('Z') || trimmed.endsWith('z')) { final localLike = DateTime.tryParse(trimmed.substring(0, trimmed.length - 1)); if (localLike != null) return localLike.millisecondsSinceEpoch; } return dt.millisecondsSinceEpoch; } } return 0; } /// Satu desimal untuk grafik kekeruhan (potong, bukan dibulatkan). double historyChartDecimal(double value) { return (value * 10).truncateToDouble() / 10; } /// Satu desimal untuk ketinggian air (dibulatkan). /// Contoh: 16.58 → 16.6 double historyWaterLevelDecimal(double value) { return (value * 10).roundToDouble() / 10; } /// Dua desimal untuk amonia double historyAmmoniaDecimal(double value) { return (value * 100).truncateToDouble() / 100; } /// Parse tinggi air hasil perhitungan (tinggi aquarium − distance). String? _rawTinggiAirValue(Map value) { return value['tinggi_air']?.toString() ?? value['tinggiAir']?.toString(); } /// Parse ketinggian/tinggi air dari baris history (Node-RED / cache lokal). String? _rawWaterLevelValue(Map value) { return _rawTinggiAirValue(value) ?? value['distance']?.toString() ?? value['water_level']?.toString() ?? value['ketinggian_air']?.toString() ?? value['jarak']?.toString() ?? value['level_air']?.toString(); } /// Parse ketinggian air dari baris history (Node-RED / cache lokal). double parseHistoryWaterLevel(Map value) { final waterRaw = _rawWaterLevelValue(value); final parsed = double.tryParse(waterRaw ?? '') ?? 0.0; return historyWaterLevelDecimal(parsed); } /// Format tinggi air live dengan pembulatan 1 desimal. String formatSensorWaterLevel(String value) { final parsed = double.tryParse(value); if (parsed == null) return value; final rounded = historyWaterLevelDecimal(parsed); if (rounded == rounded.truncateToDouble()) { return rounded.toInt().toString(); } return rounded.toStringAsFixed(1); } /// Parse tinggi air dari payload live `/get-data` (bukan distance mentah). String parseSensorWaterLevel(Map value) { return _rawTinggiAirValue(value) ?? '0'; } class NodeRedClient { final String baseUrl; Timer? _pollingTimer; final StreamController> _dataController = StreamController>.broadcast(); NodeRedClient({this.baseUrl = kNodeRedBaseUrl}); Future _postAny({ required List endpoints, required List payloads, Duration timeout = const Duration(seconds: 6), }) async { for (final endpoint in endpoints) { for (final payload in payloads) { try { final response = await http .post( _buildApiUri(endpoint), headers: {'Content-Type': 'application/json'}, body: json.encode(payload), ) .timeout(timeout); if (response.statusCode >= 200 && response.statusCode < 300) { return true; } } catch (_) {} } } return false; } // Helper URI builder Uri _buildApiUri(String endpoint) { final normalized = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; return Uri.parse('$normalized/$endpoint'); } // Stream data sensor Stream> get sensorDataStream => _dataController.stream; // Start polling void startPolling({required int intervalSeconds}) { print('Memulai polling dari Node-RED: $baseUrl'); _pollingTimer?.cancel(); _pollingTimer = Timer.periodic( const Duration(seconds: 3), (_) => _fetchSensorData(), ); } // Stop polling void stopPolling() { print('Menghentikan polling dari Node-RED'); _pollingTimer?.cancel(); _pollingTimer = null; } Future>> fetchHistory({ int limit = 100, String? date, }) async { const endpoints = ['get_history']; for (final endpoint in endpoints) { try { final query = { 'limit': '$limit', }; // Tambahkan parameter tanggal jika ada if (date != null) { query['date'] = date; } final uri = _buildApiUri(endpoint).replace( queryParameters: query, ); final response = await http .get(uri) .timeout(const Duration(seconds: 15)); if (response.statusCode != 200) { continue; } final decoded = jsonDecode(response.body); final List list; if (decoded is List) { list = decoded; } else if (decoded is Map) { final raw = decoded['items'] ?? decoded['data'] ?? decoded['history']; list = raw is List ? raw : const []; } else { list = const []; } return list .whereType() .map((e) => Map.from(e)) .toList(); } catch (e) { print('Fetch history error: $e'); } } return []; } /// Deret nilai untuk grafik (urutan waktu menaik). Future> fetchHistorySeries({ required bool ammonia, int limit = 50, }) async { final rows = await fetchHistory(limit: limit); return seriesFromHistoryRows(rows, ammoniaField: ammonia); } static List seriesFromHistoryRows( List> rows, { required bool ammoniaField, }) { final items = >[]; for (final value in rows) { final ts = parseHistoryTimestampMs( value['ts'] ?? value['time'] ?? value['timestamp'] ?? value['created_at'], ); if (ts <= 0) continue; final v = ammoniaField ? (double.tryParse( '${value['amonia'] ?? value['ammonia'] ?? value['ph'] ?? 0}', ) ?? 0.0) : (double.tryParse( '${value['kekeruhan'] ?? value['turbidity'] ?? value['ntu'] ?? 0}', ) ?? 0.0); items.add(MapEntry(ts, ammoniaField ? historyAmmoniaDecimal(v) : historyChartDecimal(v))); } items.sort((a, b) => a.key.compareTo(b.key)); return items.map((e) => e.value).toList(); } Future _fetchSensorData() async { try { final response = await http .get(_buildApiUri('get-data')) .timeout(const Duration(seconds: 5)); if (response.statusCode == 200) { try { final data = jsonDecode(response.body) as Map; _dataController.add(data); } catch (e) { print('Format JSON tidak valid: $e'); } } else { print('Gagal mengambil data: ${response.statusCode}'); } } catch (e) { print('Error mengambil data sensor: $e'); } } // Parameter amonia Future setMaxParameter(double value) async { final v = value.toStringAsFixed(1); final sent = await _postAny( endpoints: const ['set-pmaks'], payloads: [ value, v, {'maks': value}, ], ); if (sent) return true; for (final endpoint in const ['set-pmaks']) { try { final response = await http .post( _buildApiUri(endpoint), headers: {'Content-Type': 'text/plain'}, body: v, ) .timeout(const Duration(seconds: 6)); if (response.statusCode >= 200 && response.statusCode < 300) { return true; } } catch (_) {} } return false; } // Timer lampu Future setTimerLight({ required int startHour, required int startMinute, required int endHour, required int endMinute, required bool enabled, }) async { final start = '${startHour.toString().padLeft(2, '0')}:${startMinute.toString().padLeft(2, '0')}'; final end = '${endHour.toString().padLeft(2, '0')}:${endMinute.toString().padLeft(2, '0')}'; await _postAny( endpoints: const ['api-schedule'], payloads: [ { 'on_time': start, 'off_time': end, 'enabled': enabled, } ], ); } // Switch Future setLampTimerStatus(bool enabled) async { final status = enabled ? 'on' : 'off'; await _postAny( endpoints: const ['api-lampu'], payloads: [ { 'lampu': status, } ], ); } // Kekeruhan air Future setMurkyThreshold(double value) async { final rounded = value.round(); final v = '$rounded'; await _postAny( endpoints: const [ 'set-batas_kekeruhan', ], payloads: [ rounded, v, {'max_kekeruhan': v}, ], ); } // Volume air Future setVolumeLevel(int level) async { await _postAny( endpoints: const [ 'set-batas_distance', ], payloads: [ level, {'min_jarak': level}, ], ); } // Tinggi aquarium Future setAquariumHeight(int height) async { await _postAny( endpoints: const [ 'set-tinggi_aquarium', ], payloads: [ {'tinggi_aquarium': height}, height, ], ); } // Dispose void dispose() { stopPolling(); if (!_dataController.isClosed) { _dataController.close(); } } }