This commit is contained in:
kleponijo 2026-05-22 11:07:24 +07:00
parent 080c0fb577
commit c5413cdf95
7 changed files with 1117 additions and 253 deletions

View File

@ -1,26 +1,129 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:monitoring_repository/monitoring_repository.dart';
part 'device_setup_event.dart'; part 'device_setup_event.dart';
part 'device_setup_state.dart'; part 'device_setup_state.dart';
const _kDeviceIdKey = 'selected_device_id';
class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> { class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
DeviceSetupBloc() : super(const DeviceSetupState()) { final MonitoringRepository _repository;
final Dio _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 5),
));
DeviceSetupBloc({required MonitoringRepository repository})
: _repository = repository,
super(const DeviceSetupState()) {
on<CheckEspConnectionEvent>(_onCheckConnection); on<CheckEspConnectionEvent>(_onCheckConnection);
on<SendWifiCredentialsEvent>(_onSendCredentials); on<SendWifiCredentialsEvent>(_onSendCredentials);
on<ResetDeviceSetupEvent>(_onReset); on<ResetDeviceSetupEvent>(_onReset);
// Settings
on<DeviceSettingsStarted>(_onSettingsStarted);
on<DeviceIdChanged>(_onDeviceIdChanged);
on<KFaktorChanged>((e, emit) => emit(state.copyWith(kFaktor: e.value)));
on<RadiusChanged>((e, emit) => emit(state.copyWith(radiusM: e.value)));
on<IntervalRealtimeChanged>(
(e, emit) => emit(state.copyWith(intervalRealtimeMs: e.ms)));
on<IntervalHistoryChanged>(
(e, emit) => emit(state.copyWith(intervalHistoryMs: e.ms)));
on<DeviceSettingsSaved>(_onSettingsSaved);
on<DeviceLogsRefreshed>(_onLogsRefreshed);
} }
// Dio instance khusus untuk komunikasi ke ESP //
// Timeout pendek karena ESP di jaringan lokal, harusnya cepat. // Settings
// Kalau timeout berarti HP belum konek ke hotspot ESP. //
final Dio _dio = Dio(
BaseOptions( Future<void> _onSettingsStarted(
connectTimeout: const Duration(seconds: 5), DeviceSettingsStarted event,
receiveTimeout: const Duration(seconds: 10), Emitter<DeviceSetupState> emit,
sendTimeout: const Duration(seconds: 5), ) async {
), emit(state.copyWith(status: DeviceSetupStatus.settingsLoading));
); try {
// Baca device ID tersimpan lokal
final prefs = await SharedPreferences.getInstance();
final savedId = prefs.getString(_kDeviceIdKey) ?? state.deviceId;
// Baca settings dari Firebase
final s = await _repository.getAnemometerSettings();
// Baca logs
final logs = await _repository.getDeviceLogs(savedId);
emit(state.copyWith(
status: DeviceSetupStatus.settingsLoaded,
deviceId: savedId,
kFaktor: s['k_faktor'] as double,
radiusM: s['radius_m'] as double,
intervalRealtimeMs: s['interval_realtime_ms'] as int,
intervalHistoryMs: s['interval_history_ms'] as int,
logs: logs,
));
} catch (e) {
emit(state.copyWith(
status: DeviceSetupStatus.settingsError,
errorMessage: 'Gagal memuat settings: $e',
));
}
}
Future<void> _onDeviceIdChanged(
DeviceIdChanged event,
Emitter<DeviceSetupState> emit,
) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kDeviceIdKey, event.deviceId);
// Muat log device baru
emit(state.copyWith(
deviceId: event.deviceId,
logsLoading: true,
));
final logs = await _repository.getDeviceLogs(event.deviceId);
emit(state.copyWith(logs: logs, logsLoading: false));
}
Future<void> _onSettingsSaved(
DeviceSettingsSaved event,
Emitter<DeviceSetupState> emit,
) async {
emit(state.copyWith(status: DeviceSetupStatus.settingsSaving));
try {
await _repository.updateAnemometerSettings(
kFaktor: state.kFaktor,
radiusM: state.radiusM,
intervalRealtimeMs: state.intervalRealtimeMs,
intervalHistoryMs: state.intervalHistoryMs,
);
emit(state.copyWith(status: DeviceSetupStatus.settingsSaved));
await Future.delayed(const Duration(seconds: 2));
emit(state.copyWith(status: DeviceSetupStatus.settingsLoaded));
} catch (e) {
emit(state.copyWith(
status: DeviceSetupStatus.settingsError,
errorMessage: 'Gagal menyimpan: $e',
));
}
}
Future<void> _onLogsRefreshed(
DeviceLogsRefreshed event,
Emitter<DeviceSetupState> emit,
) async {
emit(state.copyWith(logsLoading: true));
final logs = await _repository.getDeviceLogs(state.deviceId);
emit(state.copyWith(logs: logs, logsLoading: false));
}
//
// WiFi Setup (existing logic, tidak berubah)
//
// Cek koneksi ke ESP // Cek koneksi ke ESP
Future<void> _onCheckConnection( Future<void> _onCheckConnection(
@ -137,6 +240,12 @@ class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
ResetDeviceSetupEvent event, ResetDeviceSetupEvent event,
Emitter<DeviceSetupState> emit, Emitter<DeviceSetupState> emit,
) { ) {
emit(const DeviceSetupState()); emit(DeviceSetupState(
deviceId: state.deviceId,
kFaktor: state.kFaktor,
radiusM: state.radiusM,
intervalRealtimeMs: state.intervalRealtimeMs,
intervalHistoryMs: state.intervalHistoryMs,
));
} }
} }

View File

@ -15,3 +15,44 @@ class SendWifiCredentialsEvent extends DeviceSetupEvent {
/// Reset state ke awal (misalnya saat user keluar screen) /// Reset state ke awal (misalnya saat user keluar screen)
class ResetDeviceSetupEvent extends DeviceSetupEvent {} class ResetDeviceSetupEvent extends DeviceSetupEvent {}
// Sensor Settings events (new)
/// Load device ID dari SharedPreferences + settings dari Firebase
class DeviceSettingsStarted extends DeviceSetupEvent {}
/// User pilih/ubah device ID (misal: esp_percobaan esp_lapangan)
class DeviceIdChanged extends DeviceSetupEvent {
final String deviceId;
DeviceIdChanged(this.deviceId);
}
/// User ubah k_faktor di form
class KFaktorChanged extends DeviceSetupEvent {
final double value;
KFaktorChanged(this.value);
}
/// User ubah radius_m di form
class RadiusChanged extends DeviceSetupEvent {
final double value;
RadiusChanged(this.value);
}
/// User ubah interval realtime (ms)
class IntervalRealtimeChanged extends DeviceSetupEvent {
final int ms;
IntervalRealtimeChanged(this.ms);
}
/// User ubah interval history (ms)
class IntervalHistoryChanged extends DeviceSetupEvent {
final int ms;
IntervalHistoryChanged(this.ms);
}
/// Simpan settings ke Firebase
class DeviceSettingsSaved extends DeviceSetupEvent {}
/// Refresh logs dari Firebase
class DeviceLogsRefreshed extends DeviceSetupEvent {}

View File

@ -8,6 +8,12 @@ enum DeviceSetupStatus {
sending, // sedang kirim SSID+password ke ESP sending, // sedang kirim SSID+password ke ESP
success, // ESP berhasil terima & akan restart success, // ESP berhasil terima & akan restart
failure, // gagal (timeout, ESP tidak respond, dll) failure, // gagal (timeout, ESP tidak respond, dll)
// Settings
settingsLoading,
settingsLoaded,
settingsSaving,
settingsSaved,
settingsError,
} }
class DeviceSetupState { class DeviceSetupState {
@ -15,12 +21,42 @@ class DeviceSetupState {
final String? errorMessage; final String? errorMessage;
final String? successMessage; final String? successMessage;
final String espIp; final String espIp;
// Sensor settings
/// ID device aktif menentukan path Firebase yang dibaca app.
/// Default 'esp_lapangan' (ESP utama di lapangan).
/// Disimpan lokal di SharedPreferences.
final String deviceId;
/// Konstanta kalibrasi. Default 50.0 (hasil estimasi vs AWS).
final double kFaktor;
/// Jari-jari lengan anemometer dalam meter. Default 0.08 (8 cm).
final double radiusM;
/// Interval pengiriman realtime ke Firebase (ms). Default 1000 = 1 detik.
final int intervalRealtimeMs;
/// Interval push history ke Firebase (ms). Default 3600000 = 1 jam.
final int intervalHistoryMs;
// Logs
final List<Map<String, dynamic>> logs;
final bool logsLoading;
const DeviceSetupState({ const DeviceSetupState({
this.status = DeviceSetupStatus.idle, this.status = DeviceSetupStatus.idle,
this.errorMessage, this.errorMessage,
this.successMessage, this.successMessage,
this.espIp = '192.168.4.1', // default IP ESP saat AP mode this.espIp = '192.168.4.1', // default IP ESP saat AP mode
// Settings default sama dengan cfg_config.h di ESP
this.deviceId = 'esp_lapangan',
this.kFaktor = 50.0,
this.radiusM = 0.08,
this.intervalRealtimeMs = 1000,
this.intervalHistoryMs = 3600000,
// Logs
this.logs = const [],
this.logsLoading = false,
}); });
DeviceSetupState copyWith({ DeviceSetupState copyWith({
@ -28,12 +64,26 @@ class DeviceSetupState {
String? errorMessage, String? errorMessage,
String? successMessage, String? successMessage,
String? espIp, String? espIp,
String? deviceId,
double? kFaktor,
double? radiusM,
int? intervalRealtimeMs,
int? intervalHistoryMs,
List<Map<String, dynamic>>? logs,
bool? logsLoading,
}) { }) {
return DeviceSetupState( return DeviceSetupState(
status: status ?? this.status, status: status ?? this.status,
errorMessage: errorMessage, errorMessage: errorMessage,
successMessage: successMessage, successMessage: successMessage,
espIp: espIp ?? this.espIp, espIp: espIp ?? this.espIp,
deviceId: deviceId ?? this.deviceId,
kFaktor: kFaktor ?? this.kFaktor,
radiusM: radiusM ?? this.radiusM,
intervalRealtimeMs: intervalRealtimeMs ?? this.intervalRealtimeMs,
intervalHistoryMs: intervalHistoryMs ?? this.intervalHistoryMs,
logs: logs ?? this.logs,
logsLoading: logsLoading ?? this.logsLoading,
); );
} }
} }

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart';
import '../../../../core/utils/time_series_mapper.dart'; import '../../../../core/utils/time_series_mapper.dart';
@ -13,6 +14,8 @@ part 'wind_speed_state.dart';
/// Referensi: Beaufort scale & BMKG /// Referensi: Beaufort scale & BMKG
const double _kWindWarning = 8.0; // Waspada: 812.5 m/s (~2945 km/h) const double _kWindWarning = 8.0; // Waspada: 812.5 m/s (~2945 km/h)
const double _kWindDanger = 12.5; // Bahaya: > 12.5 m/s (> 45 km/h) const double _kWindDanger = 12.5; // Bahaya: > 12.5 m/s (> 45 km/h)
const _kDeviceIdKey = 'selected_device_id';
const _kDefaultDeviceId = 'esp_lapangan';
class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> { class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
final MonitoringRepository _repository; final MonitoringRepository _repository;
@ -31,6 +34,12 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
on<WindSpeedDateFilterChanged>(_onDateFilterChanged); // baru on<WindSpeedDateFilterChanged>(_onDateFilterChanged); // baru
} }
// Baca device ID dari SharedPreferences
Future<String> _getDeviceId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_kDeviceIdKey) ?? _kDefaultDeviceId;
}
// //
// START // START
// //
@ -40,6 +49,9 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
) async { ) async {
emit(state.copyWith(isLoading: true)); emit(state.copyWith(isLoading: true));
// Baca device ID aktif (dari SharedPreferences, diset di DeviceSetupBloc)
final deviceId = await _getDeviceId();
final history = await _repository.getSensorHistory( final history = await _repository.getSensorHistory(
'anemometer/esp_percobaan/history', 'anemometer/esp_percobaan/history',
(json) => MyWindSpeed.fromJson(json), (json) => MyWindSpeed.fromJson(json),
@ -82,15 +94,14 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
history.isNotEmpty ? _getAlertLevel(history.last.speed) : 'Normal', history.isNotEmpty ? _getAlertLevel(history.last.speed) : 'Normal',
)); ));
// Subscribe realtime dari path device aktif
await _subscription?.cancel(); await _subscription?.cancel();
_subscription = _repository _subscription = _repository
.getSensorStream( .getSensorStream(
'anemometer/esp_percobaan/realtime', 'anemometer/$deviceId/realtime',
(json) => MyWindSpeed.fromJson(json), (json) => MyWindSpeed.fromJson(json),
) )
.listen((data) { .listen((data) => add(_WindSpeedRealtimeUpdated(data)));
add(_WindSpeedRealtimeUpdated(data));
});
} }
// //

View File

@ -1,6 +1,4 @@
// import 'dart:developer';
import 'dart:async'; import 'dart:async';
// import 'package:rxdart/rxdart.dart';
import 'package:firebase_database/firebase_database.dart'; import 'package:firebase_database/firebase_database.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
@ -15,57 +13,46 @@ class FirebaseMonitoringRepo implements MonitoringRepository {
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper,
) { ) {
/// === ambil data mentah dari firebase === ///
return _db.ref(path).onValue.map((event) { return _db.ref(path).onValue.map((event) {
final Object? value = event.snapshot.value; final Object? value = event.snapshot.value;
return mapper(value is Map ? value : {});
if (value is Map) {
return mapper(value);
} else {
// Jika data kosong atau bukan Map, berikan Map kosong agar mapper tidak crash
return mapper({});
}
}); });
} }
// Snapshot
@override @override
// Jika ingin mengambil data sekali saja (bukan stream)
Future<T> getSensorSnapshot<T>( Future<T> getSensorSnapshot<T>(
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper,
) async { ) async {
final snapshot = await _db.ref(path).get(); final snapshot = await _db.ref(path).get();
final data = snapshot.value as Map<dynamic, dynamic>? ?? {}; return mapper(
return mapper(data); snapshot.value is Map ? snapshot.value as Map<dynamic, dynamic> : {},
);
} }
// History
@override @override
Future<List<T>> getSensorHistory<T>( Future<List<T>> getSensorHistory<T>(
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper,
) async { ) async {
try { try {
// Ambil data dari path history
final snapshot = await _db.ref(path).get(); final snapshot = await _db.ref(path).get();
if (snapshot.exists && snapshot.value is Map) { if (snapshot.exists && snapshot.value is Map) {
final Map<dynamic, dynamic> data = snapshot.value as Map; final data = snapshot.value as Map<dynamic, dynamic>;
final list = data.values
final list = data.values.map((item) { .map((item) => mapper(item as Map<dynamic, dynamic>))
return mapper(item as Map<dynamic, dynamic>); .toList();
}).toList();
// Sort by timestamp Firebase push tidak selalu menghasilkan urutan kronologis.
list.sort((a, b) { list.sort((a, b) {
try { try {
final aTimestamp = (a as dynamic).timestamp as DateTime; final aT = (a as dynamic).timestamp as DateTime;
final bTimestamp = (b as dynamic).timestamp as DateTime; final bT = (b as dynamic).timestamp as DateTime;
return aTimestamp.compareTo(bTimestamp); return aT.compareTo(bT);
} catch (_) { } catch (_) {
return 0; return 0;
} }
}); });
return list; return list;
} }
return []; return [];
@ -74,29 +61,40 @@ class FirebaseMonitoringRepo implements MonitoringRepository {
} }
} }
// Baca settings dari Firebase // Anemometer Settings
@override
Future<Map<String, dynamic>> getAnemometerSettings() async { Future<Map<String, dynamic>> getAnemometerSettings() async {
// Default sama dengan cfg_config.h di ESP
const defaults = <String, dynamic>{
'k_faktor': 50.0,
'radius_m': 0.08,
'interval_realtime_ms': 1000,
'interval_history_ms': 3600000,
};
try { try {
final snapshot = await _db.ref('anemometer/settings').get(); final snapshot = await _db.ref('anemometer/settings').get();
if (snapshot.exists && snapshot.value is Map) { if (snapshot.exists && snapshot.value is Map) {
final raw = snapshot.value as Map<dynamic, dynamic>; final raw = snapshot.value as Map<dynamic, dynamic>;
return { return {
'k_faktor': (raw['k_faktor'] ?? 50.0).toDouble(), 'k_faktor': (raw['k_faktor'] ?? defaults['k_faktor']).toDouble(),
'interval_realtime_ms': (raw['interval_realtime_ms'] ?? 1000) as int, 'radius_m': (raw['radius_m'] ?? defaults['radius_m']).toDouble(),
'interval_history_ms': (raw['interval_history_ms'] ?? 3600000) as int, 'interval_realtime_ms':
(raw['interval_realtime_ms'] ?? defaults['interval_realtime_ms'])
as int,
'interval_history_ms':
(raw['interval_history_ms'] ?? defaults['interval_history_ms'])
as int,
}; };
} }
} catch (_) {} } catch (_) {}
return {
'k_faktor': 50.0, return defaults;
'interval_realtime_ms': 1000,
'interval_history_ms': 3600000,
};
} }
// Tulis settings ke Firebase (dari app) // Tulis settings ke Firebase (dari app)
Future<void> updateAnemometerSettings({ Future<void> updateAnemometerSettings({
double? kFaktor, double? kFaktor,
double? radiusM,
int? intervalRealtimeMs, int? intervalRealtimeMs,
int? intervalHistoryMs, int? intervalHistoryMs,
}) async { }) async {

View File

@ -1,7 +1,5 @@
// import 'models/models.dart';
abstract class MonitoringRepository { abstract class MonitoringRepository {
// fungsi untuk ambil data berkali/streaming // Stream & Snapshot
Stream<T> getSensorStream<T>( Stream<T> getSensorStream<T>(
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper,
@ -18,4 +16,26 @@ abstract class MonitoringRepository {
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper,
); );
// Anemometer Settings
/// Baca semua settings dari /anemometer/settings/
/// Return map dengan keys:
/// k_faktor (double), radius_m (double),
/// interval_realtime_ms (int), interval_history_ms (int)
Future<Map<String, dynamic>> getAnemometerSettings();
/// Tulis settings ke /anemometer/settings/ (field yang null tidak ditulis)
Future<void> updateAnemometerSettings({
double? kFaktor,
double? radiusM,
int? intervalRealtimeMs,
int? intervalHistoryMs,
});
// Device Logs
/// Ambil N log terakhir dari /anemometer/{deviceId}/logs
Future<List<Map<String, dynamic>>> getDeviceLogs(
String deviceId, {
int limit = 50,
});
} }