This commit is contained in:
kleponijo 2026-06-29 11:51:24 +07:00
parent 4fd56dc5f9
commit 74a1609c21
7 changed files with 70 additions and 172 deletions

View File

@ -19,27 +19,21 @@ class DeviceIdChanged extends DeviceSetupEvent {
DeviceIdChanged(this.deviceId); DeviceIdChanged(this.deviceId);
} }
class KFaktorChanged extends DeviceSetupEvent {
final double value;
KFaktorChanged(this.value);
}
class RadiusChanged extends DeviceSetupEvent {
final double value;
RadiusChanged(this.value);
}
class IntervalRealtimeChanged extends DeviceSetupEvent { class IntervalRealtimeChanged extends DeviceSetupEvent {
final int ms; final int ms;
IntervalRealtimeChanged(this.ms); IntervalRealtimeChanged(this.ms);
} }
class IntervalAverageChanged extends DeviceSetupEvent {
final int ms;
IntervalAverageChanged(this.ms);
}
class IntervalHistoryChanged extends DeviceSetupEvent { class IntervalHistoryChanged extends DeviceSetupEvent {
final int ms; final int ms;
IntervalHistoryChanged(this.ms); IntervalHistoryChanged(this.ms);
} }
/// User ubah jumlah magnet (1 atau 3)
class MagnetCountChanged extends DeviceSetupEvent { class MagnetCountChanged extends DeviceSetupEvent {
final int count; final int count;
MagnetCountChanged(this.count); MagnetCountChanged(this.count);

View File

@ -22,9 +22,8 @@ class DeviceSetupState {
final String espIp; final String espIp;
final String deviceId; final String deviceId;
final double kFaktor;
final double radiusM;
final int intervalRealtimeMs; final int intervalRealtimeMs;
final int intervalAverageMs;
final int intervalHistoryMs; final int intervalHistoryMs;
final int magnetCount; final int magnetCount;
final List<Map<String, dynamic>> logs; final List<Map<String, dynamic>> logs;
@ -38,9 +37,8 @@ class DeviceSetupState {
this.successMessage, this.successMessage,
this.espIp = '192.168.4.1', this.espIp = '192.168.4.1',
this.deviceId = 'esp_lapangan', this.deviceId = 'esp_lapangan',
this.kFaktor = 50.0,
this.radiusM = 0.08,
this.intervalRealtimeMs = 1000, this.intervalRealtimeMs = 1000,
this.intervalAverageMs = 60000,
this.intervalHistoryMs = 3600000, this.intervalHistoryMs = 3600000,
this.magnetCount = 1, // default 1 magnet this.magnetCount = 1, // default 1 magnet
this.logs = const [], this.logs = const [],
@ -58,9 +56,8 @@ class DeviceSetupState {
String? successMessage, String? successMessage,
String? espIp, String? espIp,
String? deviceId, String? deviceId,
double? kFaktor,
double? radiusM,
int? intervalRealtimeMs, int? intervalRealtimeMs,
int? intervalAverageMs,
int? intervalHistoryMs, int? intervalHistoryMs,
int? magnetCount, int? magnetCount,
List<Map<String, dynamic>>? logs, List<Map<String, dynamic>>? logs,
@ -74,9 +71,8 @@ class DeviceSetupState {
successMessage: successMessage, successMessage: successMessage,
espIp: espIp ?? this.espIp, espIp: espIp ?? this.espIp,
deviceId: deviceId ?? this.deviceId, deviceId: deviceId ?? this.deviceId,
kFaktor: kFaktor ?? this.kFaktor,
radiusM: radiusM ?? this.radiusM,
intervalRealtimeMs: intervalRealtimeMs ?? this.intervalRealtimeMs, intervalRealtimeMs: intervalRealtimeMs ?? this.intervalRealtimeMs,
intervalAverageMs: intervalAverageMs ?? this.intervalAverageMs,
intervalHistoryMs: intervalHistoryMs ?? this.intervalHistoryMs, intervalHistoryMs: intervalHistoryMs ?? this.intervalHistoryMs,
magnetCount: magnetCount ?? this.magnetCount, magnetCount: magnetCount ?? this.magnetCount,
logs: logs ?? this.logs, logs: logs ?? this.logs,

View File

@ -26,10 +26,10 @@ class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
// Settings // Settings
on<DeviceSettingsStarted>(_onSettingsStarted); on<DeviceSettingsStarted>(_onSettingsStarted);
on<DeviceIdChanged>(_onDeviceIdChanged); 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>( on<IntervalRealtimeChanged>(
(e, emit) => emit(state.copyWith(intervalRealtimeMs: e.ms))); (e, emit) => emit(state.copyWith(intervalRealtimeMs: e.ms)));
on<IntervalAverageChanged>(
(e, emit) => emit(state.copyWith(intervalAverageMs: e.ms)));
on<IntervalHistoryChanged>( on<IntervalHistoryChanged>(
(e, emit) => emit(state.copyWith(intervalHistoryMs: e.ms))); (e, emit) => emit(state.copyWith(intervalHistoryMs: e.ms)));
on<MagnetCountChanged>( on<MagnetCountChanged>(
@ -56,19 +56,14 @@ class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
// Baca device ID tersimpan lokal // Baca device ID tersimpan lokal
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final savedId = prefs.getString(_kDeviceIdKey) ?? state.deviceId; final savedId = prefs.getString(_kDeviceIdKey) ?? state.deviceId;
// Baca settings dari Firebase
final s = await _repository.getAnemometerSettings(); final s = await _repository.getAnemometerSettings();
// Baca logs
final logs = await _repository.getDeviceLogs(savedId); final logs = await _repository.getDeviceLogs(savedId);
emit(state.copyWith( emit(state.copyWith(
status: DeviceSetupStatus.settingsLoaded, status: DeviceSetupStatus.settingsLoaded,
deviceId: savedId, deviceId: savedId,
kFaktor: s['k_faktor'] as double,
radiusM: s['radius_m'] as double,
intervalRealtimeMs: s['interval_realtime_ms'] as int, intervalRealtimeMs: s['interval_realtime_ms'] as int,
intervalAverageMs: s['interval_average_ms'] as int,
intervalHistoryMs: s['interval_history_ms'] as int, intervalHistoryMs: s['interval_history_ms'] as int,
magnetCount: (s['magnet_count'] as int?) ?? 1, magnetCount: (s['magnet_count'] as int?) ?? 1,
logs: logs, logs: logs,
@ -104,9 +99,8 @@ class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
emit(state.copyWith(status: DeviceSetupStatus.settingsSaving)); emit(state.copyWith(status: DeviceSetupStatus.settingsSaving));
try { try {
await _repository.updateAnemometerSettings( await _repository.updateAnemometerSettings(
kFaktor: state.kFaktor,
radiusM: state.radiusM,
intervalRealtimeMs: state.intervalRealtimeMs, intervalRealtimeMs: state.intervalRealtimeMs,
intervalAverageMs: state.intervalAverageMs,
intervalHistoryMs: state.intervalHistoryMs, intervalHistoryMs: state.intervalHistoryMs,
magnetCount: state.magnetCount, magnetCount: state.magnetCount,
); );
@ -335,9 +329,8 @@ class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
) { ) {
emit(DeviceSetupState( emit(DeviceSetupState(
deviceId: state.deviceId, deviceId: state.deviceId,
kFaktor: state.kFaktor,
radiusM: state.radiusM,
intervalRealtimeMs: state.intervalRealtimeMs, intervalRealtimeMs: state.intervalRealtimeMs,
intervalAverageMs: state.intervalAverageMs,
intervalHistoryMs: state.intervalHistoryMs, intervalHistoryMs: state.intervalHistoryMs,
)); ));
} }

View File

@ -18,6 +18,14 @@ const _kRealtimeOptions = <String, int>{
'1 jam': 3600000, '1 jam': 3600000,
}; };
const _kAverageOptions = <String, int>{
'30 detik': 30000,
'1 menit': 60000,
'5 menit': 300000,
'10 menit': 600000,
'30 menit': 1800000,
};
// Opsi interval history // Opsi interval history
const _kHistoryOptions = <String, int>{ const _kHistoryOptions = <String, int>{
'10 menit': 600000, '10 menit': 600000,
@ -45,8 +53,6 @@ class _DeviceSetupScreenState extends State<DeviceSetupScreen>
bool _obscurePassword = true; bool _obscurePassword = true;
// Settings form controllers // Settings form controllers
final _kFaktorController = TextEditingController();
final _radiusController = TextEditingController();
String? _customDeviceId; String? _customDeviceId;
@override @override
@ -60,20 +66,11 @@ class _DeviceSetupScreenState extends State<DeviceSetupScreen>
_tabController.dispose(); _tabController.dispose();
_ssidController.dispose(); _ssidController.dispose();
_passwordController.dispose(); _passwordController.dispose();
_kFaktorController.dispose();
_radiusController.dispose();
super.dispose(); super.dispose();
} }
// Sync text controllers dengan state saat settings loaded // Sync text controllers dengan state saat settings loaded
void _syncControllers(DeviceSetupState state) { void _syncControllers(DeviceSetupState state) {}
if (_kFaktorController.text != state.kFaktor.toString()) {
_kFaktorController.text = state.kFaktor.toStringAsFixed(2);
}
if (_radiusController.text != state.radiusM.toString()) {
_radiusController.text = state.radiusM.toStringAsFixed(3);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -144,8 +141,6 @@ class _DeviceSetupScreenState extends State<DeviceSetupScreen>
children: [ children: [
_SensorSettingsTab( _SensorSettingsTab(
state: state, state: state,
kFaktorController: _kFaktorController,
radiusController: _radiusController,
customDeviceId: _customDeviceId, customDeviceId: _customDeviceId,
onCustomDeviceIdChanged: (v) => onCustomDeviceIdChanged: (v) =>
setState(() => _customDeviceId = v), setState(() => _customDeviceId = v),
@ -215,15 +210,11 @@ class _DeviceSetupScreenState extends State<DeviceSetupScreen>
// //
class _SensorSettingsTab extends StatelessWidget { class _SensorSettingsTab extends StatelessWidget {
final DeviceSetupState state; final DeviceSetupState state;
final TextEditingController kFaktorController;
final TextEditingController radiusController;
final String? customDeviceId; final String? customDeviceId;
final ValueChanged<String?> onCustomDeviceIdChanged; final ValueChanged<String?> onCustomDeviceIdChanged;
const _SensorSettingsTab({ const _SensorSettingsTab({
required this.state, required this.state,
required this.kFaktorController,
required this.radiusController,
required this.customDeviceId, required this.customDeviceId,
required this.onCustomDeviceIdChanged, required this.onCustomDeviceIdChanged,
}); });
@ -259,36 +250,6 @@ class _SensorSettingsTab extends StatelessWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Kalibrasi
_sectionTitle('Kalibrasi'),
const SizedBox(height: 10),
_SettingsCard(children: [
_NumericField(
controller: kFaktorController,
label: 'K-Faktor',
hint: 'Contoh: 50.0',
suffix: '×',
helper: 'Konstanta kalibrasi vs AWS. Awal: 50.0',
onChanged: (v) {
final d = double.tryParse(v);
if (d != null && d > 0) bloc.add(KFaktorChanged(d));
},
),
const Divider(height: 24),
_NumericField(
controller: radiusController,
label: 'Jari-jari Lengan',
hint: 'Contoh: 0.08',
suffix: 'm',
helper: 'Jarak pusat ke ujung cup anemometer. Default: 0.08 m',
onChanged: (v) {
final d = double.tryParse(v);
if (d != null && d > 0) bloc.add(RadiusChanged(d));
},
),
]),
const SizedBox(height: 24),
// Jumlah Magnet // Jumlah Magnet
_sectionTitle('Jumlah Magnet'), _sectionTitle('Jumlah Magnet'),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -320,6 +281,21 @@ class _SensorSettingsTab extends StatelessWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
_sectionTitle('Interval Rata-rata (Average)'),
const SizedBox(height: 4),
Text(
'Seberapa sering data rata-rata dikirim ke Firebase. '
'Harus lebih pendek dari interval history.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
const SizedBox(height: 10),
_ChipSelector(
options: _kAverageOptions,
selectedMs: state.intervalAverageMs,
onSelected: (ms) => bloc.add(IntervalAverageChanged(ms)),
),
const SizedBox(height: 24),
// Interval History // Interval History
_sectionTitle('Interval History (Rata-rata Jangka Panjang)'), _sectionTitle('Interval History (Rata-rata Jangka Panjang)'),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -969,66 +945,6 @@ class _ChipSelector extends StatelessWidget {
} }
} }
class _NumericField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String hint;
final String suffix;
final String helper;
final ValueChanged<String> onChanged;
const _NumericField({
required this.controller,
required this.label,
required this.hint,
required this.suffix,
required this.helper,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))],
decoration: InputDecoration(
labelText: label,
hintText: hint,
suffixText: suffix,
helperText: helper,
helperMaxLines: 2,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
isDense: true,
),
onChanged: onChanged,
);
}
}
class _SettingsCard extends StatelessWidget {
final List<Widget> children;
const _SettingsCard({required this.children});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: children),
);
}
}
class _MagnetCountSelector extends StatelessWidget { class _MagnetCountSelector extends StatelessWidget {
final int selected; final int selected;
final ValueChanged<int> onSelected; final ValueChanged<int> onSelected;

View File

@ -103,46 +103,49 @@ class FirebaseMonitoringRepo implements MonitoringRepository {
Future<Map<String, dynamic>> getAnemometerSettings() async { Future<Map<String, dynamic>> getAnemometerSettings() async {
// Default sama dengan cfg_config.h di ESP // Default sama dengan cfg_config.h di ESP
const defaults = <String, dynamic>{ const defaults = <String, dynamic>{
'k_faktor': 50.0,
'radius_m': 0.08,
'interval_realtime_ms': 1000, 'interval_realtime_ms': 1000,
'interval_average_ms': 60000,
'interval_history_ms': 3600000, 'interval_history_ms': 3600000,
'magnet_count': 3,
}; };
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'] ?? defaults['k_faktor']).toDouble(),
'radius_m': (raw['radius_m'] ?? defaults['radius_m']).toDouble(),
'interval_realtime_ms': 'interval_realtime_ms':
(raw['interval_realtime_ms'] ?? defaults['interval_realtime_ms']) (raw['interval_realtime_ms'] ?? defaults['interval_realtime_ms'])
as int, as int,
'interval_average_ms':
(raw['interval_average_ms'] ?? defaults['interval_average_ms'])
as int,
'interval_history_ms': 'interval_history_ms':
(raw['interval_history_ms'] ?? defaults['interval_history_ms']) (raw['interval_history_ms'] ?? defaults['interval_history_ms'])
as int, as int,
'magnet_count':
(raw['magnet_count'] as int? ?? defaults['magnet_count']) as int,
}; };
} }
} catch (_) {} } catch (_) {}
return defaults; return defaults;
} }
// Tulis settings ke Firebase (dari app) // Tulis settings ke Firebase (dari app)
@override @override
Future<void> updateAnemometerSettings({ Future<void> updateAnemometerSettings({
double? kFaktor,
double? radiusM,
int? intervalRealtimeMs, int? intervalRealtimeMs,
int? intervalAverageMs,
int? intervalHistoryMs, int? intervalHistoryMs,
int? magnetCount, int? magnetCount,
}) async { }) async {
final updates = <String, dynamic>{}; final updates = <String, dynamic>{};
if (kFaktor != null) updates['k_faktor'] = kFaktor;
if (intervalRealtimeMs != null) if (intervalRealtimeMs != null)
updates['interval_realtime_ms'] = intervalRealtimeMs; updates['interval_realtime_ms'] = intervalRealtimeMs;
if (intervalAverageMs != null)
updates['interval_average_ms'] = intervalAverageMs;
if (intervalHistoryMs != null) if (intervalHistoryMs != null)
updates['interval_history_ms'] = intervalHistoryMs; updates['interval_history_ms'] = intervalHistoryMs;
if (magnetCount != null) updates['magnet_count'] = magnetCount;
if (updates.isNotEmpty) { if (updates.isNotEmpty) {
await _db.ref('anemometer/settings').update(updates); await _db.ref('anemometer/settings').update(updates);
} }

View File

@ -1,31 +1,38 @@
class MyWindSpeed { class MyWindSpeed {
final double speed; // realtime: speed_ms | history: avg_ms final double windwegKm;
final double maxSpeed; // history only (0 jika dari realtime) final double maxWindwegKm;
final double kFaktor;
final int sampleCount; final int sampleCount;
final DateTime timestamp; final DateTime timestamp;
MyWindSpeed({ MyWindSpeed({
required this.speed, required this.windwegKm,
this.maxSpeed = 0.0, this.maxWindwegKm = 0.0,
this.kFaktor = 1.0,
this.sampleCount = 0, this.sampleCount = 0,
required this.timestamp, required this.timestamp,
}); });
// Getter alias untuk kompatibilitas dengan bloc/chart yang pakai .speed
double get speed => windwegKm;
double get maxSpeed => maxWindwegKm;
static final empty = MyWindSpeed( static final empty = MyWindSpeed(
speed: 0.0, windwegKm: 0.0,
timestamp: DateTime.fromMillisecondsSinceEpoch(0), timestamp: DateTime.fromMillisecondsSinceEpoch(0),
); );
// Dipakai untuk realtime node
factory MyWindSpeed.fromJson(Map<dynamic, dynamic> json) { factory MyWindSpeed.fromJson(Map<dynamic, dynamic> json) {
return MyWindSpeed( return MyWindSpeed(
// support field lama 'speed' dan baru 'speed_ms'/'avg_ms' // realtime windweg_km
speed: ((json['speed_ms'] ?? json['avg_ms'] ?? json['speed']) ?? 0) // history avg_windweg_km
// average windweg_km
windwegKm:
((json['windweg_km'] ??
json['avg_windweg_km'] ??
json['speed_ms'] ?? // fallback data lama
json['speed'] ??
0))
.toDouble(), .toDouble(),
maxSpeed: (json['max_ms'] ?? 0).toDouble(), maxWindwegKm: (json['max_windweg_km'] ?? 0).toDouble(),
kFaktor: (json['k_faktor'] ?? 1.0).toDouble(),
sampleCount: (json['sample_count'] ?? 0) as int, sampleCount: (json['sample_count'] ?? 0) as int,
timestamp: DateTime.fromMillisecondsSinceEpoch( timestamp: DateTime.fromMillisecondsSinceEpoch(
(json['timestamp'] ?? 0) * 1000, (json['timestamp'] ?? 0) * 1000,

View File

@ -17,18 +17,11 @@ abstract class MonitoringRepository {
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(); Future<Map<String, dynamic>> getAnemometerSettings();
/// Tulis settings ke /anemometer/settings/ (field yang null tidak ditulis)
Future<void> updateAnemometerSettings({ Future<void> updateAnemometerSettings({
double? kFaktor,
double? radiusM,
int? intervalRealtimeMs, int? intervalRealtimeMs,
int? intervalAverageMs,
int? intervalHistoryMs, int? intervalHistoryMs,
int? magnetCount, int? magnetCount,
}); });
@ -41,15 +34,11 @@ abstract class MonitoringRepository {
}); });
Future<void> sendRemoteRestart(String deviceId); Future<void> sendRemoteRestart(String deviceId);
Future<void> deleteDeviceLogs(String deviceId, List<String> keys); Future<void> deleteDeviceLogs(String deviceId, List<String> keys);
Future<Map<String, T>> getSensorHistoryWithKeys<T>( Future<Map<String, T>> getSensorHistoryWithKeys<T>(
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper,
); );
Future<void> deleteHistoryByKeys(String path, List<String> keys); Future<void> deleteHistoryByKeys(String path, List<String> keys);
Future<void> deleteAllHistory(String path); Future<void> deleteAllHistory(String path);
} }