TKK_E32232198/lib/http/node_red_client.dart

374 lines
9.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String, dynamic> value) {
return value['tinggi_air']?.toString() ??
value['tinggiAir']?.toString();
}
/// Parse ketinggian/tinggi air dari baris history (Node-RED / cache lokal).
String? _rawWaterLevelValue(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> value) {
return _rawTinggiAirValue(value) ?? '0';
}
class NodeRedClient {
final String baseUrl;
Timer? _pollingTimer;
final StreamController<Map<String, dynamic>> _dataController =
StreamController<Map<String, dynamic>>.broadcast();
NodeRedClient({this.baseUrl = kNodeRedBaseUrl});
Future<bool> _postAny({
required List<String> endpoints,
required List<dynamic> 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<Map<String, dynamic>> 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<List<Map<String, dynamic>>> fetchHistory({
int limit = 100,
String? date,
}) async {
const endpoints = ['get_history'];
for (final endpoint in endpoints) {
try {
final query = <String, String>{
'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<dynamic> list;
if (decoded is List) {
list = decoded;
} else if (decoded is Map<String, dynamic>) {
final raw =
decoded['items'] ??
decoded['data'] ??
decoded['history'];
list = raw is List ? raw : const [];
} else {
list = const [];
}
return list
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
} catch (e) {
print('Fetch history error: $e');
}
}
return [];
}
/// Deret nilai untuk grafik (urutan waktu menaik).
Future<List<double>> fetchHistorySeries({
required bool ammonia,
int limit = 50,
}) async {
final rows = await fetchHistory(limit: limit);
return seriesFromHistoryRows(rows, ammoniaField: ammonia);
}
static List<double> seriesFromHistoryRows(
List<Map<String, dynamic>> rows, {
required bool ammoniaField,
}) {
final items = <MapEntry<int, double>>[];
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<void> _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<String, dynamic>;
_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<bool> 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<void> 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<void> setLampTimerStatus(bool enabled) async {
final status = enabled ? 'on' : 'off';
await _postAny(
endpoints: const ['api-lampu'],
payloads: [
{
'lampu': status,
}
],
);
}
// Kekeruhan air
Future<void> 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<void> setVolumeLevel(int level) async {
await _postAny(
endpoints: const [
'set-batas_distance',
],
payloads: [
level,
{'min_jarak': level},
],
);
}
// Tinggi aquarium
Future<void> 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();
}
}
}