374 lines
9.8 KiB
Dart
374 lines
9.8 KiB
Dart
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();
|
||
}
|
||
}
|
||
} |