Compare commits

..

10 Commits

16 changed files with 1790 additions and 58 deletions
@@ -52,8 +52,49 @@ class PhoenixSensorRepository implements SensorRepository {
Future<Sensor> unplaceSensor(int id) async => Future<Sensor> unplaceSensor(int id) async =>
Sensor.fromJson(await client.unplaceSensor(id)); Sensor.fromJson(await client.unplaceSensor(id));
@override
Future<String?> getVersion(int id) => client.getVersion(id);
@override @override
Stream<Map<String, dynamic>> sensorEvents() => realtime Stream<Map<String, dynamic>> sensorEvents() => realtime
.channelMessages('sensors') .channelMessages('sensors')
.map((m) => {'event': m.event, ...m.payload}); .map((m) => {'event': m.event, ...m.payload});
@override
Future<int> beginCalibration(int id) async {
final json = await client.beginCalibration(id);
return json['samples_needed'] as int;
}
@override
Future<void> startStage(int id, double distance) =>
client.startStage(id, distance);
@override
Future<({double rssiRef, double pathLossExp})> finishCalibration(
int id) async {
final json = await client.finishCalibration(id);
return (
rssiRef: (json['rssi_ref'] as num).toDouble(),
pathLossExp: (json['path_loss_exp'] as num).toDouble(),
);
}
@override
Future<int> setCalibrationTag(int id, String tagId) async {
final json = await client.setCalibrationTag(id, tagId);
return json['samples_needed'] as int;
}
@override
Future<void> cancelCalibration(int id) => client.cancelCalibration(id);
@override
Stream<({String event, Map<String, dynamic> payload})> calibrationEvents(
String sensorDeviceId) =>
realtime.channelMessages('calibration:$sensorDeviceId');
@override
void leaveCalibrationChannel(String sensorDeviceId) =>
realtime.leaveChannel('calibration:$sensorDeviceId');
} }
@@ -38,19 +38,42 @@ class PhoenixTagRepository implements TagRepository {
Future<void> deleteTag(int id) => tagClient.deleteTag(id); Future<void> deleteTag(int id) => tagClient.deleteTag(id);
@override @override
Stream<List<TagPosition>> watchPositions() => Stream<List<TagPosition>> watchPositions() async* {
realtime.channel('tags').map((payload) { var tagMap = await _fetchTagMap();
final list = payload['positions'] as List<dynamic>? ?? [];
return list.map((j) { await for (final occupancy in watchRoomOccupancy()) {
final m = j as Map<String, dynamic>; // Re-fetch on every occupancy change so name/color edits and new tags
return TagPosition( // are reflected without restarting the stream.
tagId: m['tag_id'] as String, tagMap = await _fetchTagMap();
name: m['name'] as String,
color: m['color'] as String, final positions = <TagPosition>[];
roomId: m['room_id'] as int?, for (final entry in occupancy.entries) {
); for (final tagId in entry.value) {
}).toList(); final tag = tagMap[tagId];
}); positions.add(TagPosition(
tagId: tagId,
name: tag?.name ?? tagId,
color: tag?.color ?? _colorForTagId(tagId),
roomId: entry.key,
));
}
}
yield positions;
}
}
Future<Map<String, Tag>> _fetchTagMap() async {
final raw = await tagClient.getTags();
return {
for (final j in raw.cast<Map<String, dynamic>>())
j['tag_id'] as String: Tag.fromJson(j),
};
}
static String _colorForTagId(String tagId) {
final hash = tagId.codeUnits.fold(0, (h, c) => (h * 31 + c) & 0xFFFFFF);
return '#${hash.toRadixString(16).padLeft(6, '0')}';
}
@override @override
Stream<ParticleSnapshot> watchParticleCloud(String tagId) => Stream<ParticleSnapshot> watchParticleCloud(String tagId) =>
@@ -11,7 +11,38 @@ abstract class SensorRepository {
{required int roomId, required double x, required double y}); {required int roomId, required double x, required double y});
Future<Sensor> unplaceSensor(int id); Future<Sensor> unplaceSensor(int id);
Future<String?> getVersion(int id);
/// Stream of raw SensorsChannel messages. Each map contains an `event` key /// Stream of raw SensorsChannel messages. Each map contains an `event` key
/// (`sensor_announced` or `sensor_enrollment_timeout`) plus the payload. /// (`sensor_announced` or `sensor_enrollment_timeout`) plus the payload.
Stream<Map<String, dynamic>> sensorEvents(); Stream<Map<String, dynamic>> sensorEvents();
// ---------------------------------------------------------------------------
// Calibration
// ---------------------------------------------------------------------------
/// Enters calibration mode on the sensor. Returns the number of samples the
/// server will collect per stage.
Future<int> beginCalibration(int id);
/// Starts a collection stage at [distance] metres.
Future<void> startStage(int id, double distance);
/// Runs least-squares regression over all completed stages and persists the
/// result. Returns the fitted (rssiRef, pathLossExp) pair.
Future<({double rssiRef, double pathLossExp})> finishCalibration(int id);
/// Commits a specific tag for calibration stages. Returns samples_needed.
Future<int> setCalibrationTag(int id, String tagId);
/// Aborts calibration and discards all accumulated stage data.
Future<void> cancelCalibration(int id);
/// Real-time events from the server's CalibrationChannel.
/// Topic: `calibration:{sensorDeviceId}` (the string BLE device ID).
Stream<({String event, Map<String, dynamic> payload})> calibrationEvents(
String sensorDeviceId);
/// Leaves the calibration Phoenix channel for [sensorDeviceId].
void leaveCalibrationChannel(String sensorDeviceId);
} }
@@ -78,6 +78,12 @@ class RealtimeDataClient {
.map((msg) => (event: msg.event.value, payload: msg.payload ?? const {})); .map((msg) => (event: msg.event.value, payload: msg.payload ?? const {}));
} }
/// Leaves [topic] and removes it from the channel cache.
void leaveChannel(String topic) {
final ch = _channels.remove(topic);
ch?.leave();
}
/// Pushes [event] on [topic] and waits for the server reply. /// Pushes [event] on [topic] and waits for the server reply.
/// The channel must have been joined first via [channel]. /// The channel must have been joined first via [channel].
Future<Map<String, dynamic>> push( Future<Map<String, dynamic>> push(
+41 -14
View File
@@ -13,30 +13,57 @@ class SensorClient extends LocaliserdClient {
await get('/api/sensors/$id') as Map<String, dynamic>; await get('/api/sensors/$id') as Map<String, dynamic>;
Future<Map<String, dynamic>> updateSensor( Future<Map<String, dynamic>> updateSensor(
int id, Map<String, dynamic> params) async => int id,
await put('/api/sensors/$id', params) as Map<String, dynamic>; Map<String, dynamic> params,
) async => await put('/api/sensors/$id', params) as Map<String, dynamic>;
Future<void> deleteSensor(int id) => delete('/api/sensors/$id'); Future<void> deleteSensor(int id) => delete('/api/sensors/$id');
Future<Map<String, dynamic>> placeSensor( Future<Map<String, dynamic>> placeSensor(
int id, Map<String, dynamic> params) async => int id,
Map<String, dynamic> params,
) async =>
await put('/api/sensors/$id/place', params) as Map<String, dynamic>; await put('/api/sensors/$id/place', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> unplaceSensor(int id) async => Future<Map<String, dynamic>> unplaceSensor(int id) async =>
await deleteBody('/api/sensors/$id/place') as Map<String, dynamic>; await deleteBody('/api/sensors/$id/place') as Map<String, dynamic>;
Future<Map<String, dynamic>> createSensor(String sensorId, Future<Map<String, dynamic>> createSensor(
{String? name}) async => String sensorId, {
String? name,
}) async =>
await post('/api/sensors', { await post('/api/sensors', {
'sensor_id': sensorId, 'sensor_id': sensorId,
if (name != null) 'name': name, if (name != null) 'name': name,
}) as Map<String, dynamic>; })
as Map<String, dynamic>;
Future<Map<String, dynamic>> startCalibration( Future<Map<String, dynamic>> beginCalibration(int id) async =>
int id, double referenceDistance) async => await post('/api/sensors/$id/calibration/begin') as Map<String, dynamic>;
await post('/api/sensors/$id/calibration/start',
{'reference_distance': referenceDistance}) as Map<String, dynamic>;
Future<Map<String, dynamic>> stopCalibration(int id) async => Future<Map<String, dynamic>> startStage(int id, double distance) async =>
await post('/api/sensors/$id/calibration/stop') as Map<String, dynamic>; await post('/api/sensors/$id/calibration/stage', {
'distance': distance,
})
as Map<String, dynamic>;
Future<Map<String, dynamic>> finishCalibration(int id) async =>
await post(
'/api/sensors/$id/calibration/finish',
) as Map<String, dynamic>;
Future<Map<String, dynamic>> setCalibrationTag(int id, String tagId) async =>
await post('/api/sensors/$id/calibration/tag', {
'tag_id': tagId,
})
as Map<String, dynamic>;
Future<void> cancelCalibration(int id) =>
delete('/api/sensors/$id/calibration');
Future<String> getVersion(int id) async {
final response =
(await get('/api/sensors/$id/version') as Map<String, dynamic>);
return response['version']!;
}
} }
+86
View File
@@ -0,0 +1,86 @@
enum CalibrationPhase { idle, selectingTag, ready, collecting, done, error }
const calibrationDistances = [0.5, 1.0, 2.0, 3.0];
class CalibrationState {
const CalibrationState({
this.phase = CalibrationPhase.idle,
this.samplesNeeded = 0,
this.samplesCollected = 0,
this.selectedDistance,
this.completedDistances = const {},
this.waveform = const [],
this.latestRssi,
this.avgRssi,
this.resultRssiRef,
this.resultPathLossExp,
this.tagRssiReadings = const {},
this.selectedTagId,
this.error,
});
final CalibrationPhase phase;
final int samplesNeeded;
final int samplesCollected;
final double? selectedDistance;
final Set<double> completedDistances;
/// Circular buffer of recent RSSI readings for the waveform, max 30.
final List<double> waveform;
final double? latestRssi;
final double? avgRssi;
final double? resultRssiRef;
final double? resultPathLossExp;
/// Latest scan RSSI per tag device ID, updated during selectingTag phase.
final Map<String, double> tagRssiReadings;
/// The tag device ID committed for this calibration session.
final String? selectedTagId;
final String? error;
bool get canFinish =>
completedDistances.length >= 2 && phase != CalibrationPhase.collecting;
double get progress =>
samplesNeeded == 0 ? 0 : samplesCollected / samplesNeeded;
CalibrationState copyWith({
CalibrationPhase? phase,
int? samplesNeeded,
int? samplesCollected,
double? selectedDistance,
Set<double>? completedDistances,
List<double>? waveform,
double? latestRssi,
double? avgRssi,
double? resultRssiRef,
double? resultPathLossExp,
Map<String, double>? tagRssiReadings,
String? selectedTagId,
String? error,
bool clearSelectedDistance = false,
bool clearLatestRssi = false,
bool clearAvgRssi = false,
bool clearTagRssiReadings = false,
bool clearSelectedTagId = false,
bool clearError = false,
}) =>
CalibrationState(
phase: phase ?? this.phase,
samplesNeeded: samplesNeeded ?? this.samplesNeeded,
samplesCollected: samplesCollected ?? this.samplesCollected,
selectedDistance: clearSelectedDistance
? null
: selectedDistance ?? this.selectedDistance,
completedDistances: completedDistances ?? this.completedDistances,
waveform: waveform ?? this.waveform,
latestRssi: clearLatestRssi ? null : latestRssi ?? this.latestRssi,
avgRssi: clearAvgRssi ? null : avgRssi ?? this.avgRssi,
resultRssiRef: resultRssiRef ?? this.resultRssiRef,
resultPathLossExp: resultPathLossExp ?? this.resultPathLossExp,
tagRssiReadings: clearTagRssiReadings
? const {}
: tagRssiReadings ?? this.tagRssiReadings,
selectedTagId:
clearSelectedTagId ? null : selectedTagId ?? this.selectedTagId,
error: clearError ? null : error ?? this.error,
);
}
+5 -1
View File
@@ -7,6 +7,7 @@ class Sensor {
this.roomId, this.roomId,
this.x, this.x,
this.y, this.y,
this.version,
}); });
final int id; final int id;
@@ -16,6 +17,7 @@ class Sensor {
final int? roomId; final int? roomId;
final double? x; // room relative final double? x; // room relative
final double? y; // room relative final double? y; // room relative
final String? version;
bool get isPlaced => roomId != null && x != null && y != null; bool get isPlaced => roomId != null && x != null && y != null;
String get displayName => name ?? sensorId; String get displayName => name ?? sensorId;
@@ -28,6 +30,7 @@ class Sensor {
roomId: json['room_id'] as int?, roomId: json['room_id'] as int?,
x: (json['x'] as num?)?.toDouble(), x: (json['x'] as num?)?.toDouble(),
y: (json['y'] as num?)?.toDouble(), y: (json['y'] as num?)?.toDouble(),
version: json['version'] as String?,
); );
Sensor copyWith({ Sensor copyWith({
@@ -35,7 +38,7 @@ class Sensor {
int? roomId, int? roomId,
double? x, double? x,
double? y, double? y,
double? rssiRef, String? version,
}) => }) =>
Sensor( Sensor(
id: id, id: id,
@@ -45,5 +48,6 @@ class Sensor {
roomId: roomId ?? this.roomId, roomId: roomId ?? this.roomId,
x: x ?? this.x, x: x ?? this.x,
y: y ?? this.y, y: y ?? this.y,
version: version ?? this.version,
); );
} }
+4
View File
@@ -8,11 +8,13 @@ class Tag {
this.currentRoomId, this.currentRoomId,
this.lastPosition, this.lastPosition,
this.lastSeen, this.lastSeen,
this.color,
}); });
final int id; final int id;
final String tagId; // BLE beacon advertised identifier final String tagId; // BLE beacon advertised identifier
final String name; final String name;
final String? color;
final String? currentRoomId; final String? currentRoomId;
final Position? lastPosition; final Position? lastPosition;
final DateTime? lastSeen; final DateTime? lastSeen;
@@ -34,6 +36,7 @@ class Tag {
'current_room_id': currentRoomId, 'current_room_id': currentRoomId,
'last_position': lastPosition?.toJson(), 'last_position': lastPosition?.toJson(),
'last_seen': lastSeen?.toIso8601String(), 'last_seen': lastSeen?.toIso8601String(),
'color': color,
}; };
factory Tag.fromJson(Map<String, dynamic> json) => Tag( factory Tag.fromJson(Map<String, dynamic> json) => Tag(
@@ -41,6 +44,7 @@ class Tag {
tagId: json['tag_id'] as String, tagId: json['tag_id'] as String,
name: json['name'] as String, name: json['name'] as String,
currentRoomId: json['current_room_id'] as String?, currentRoomId: json['current_room_id'] as String?,
color: json['color'] as String?,
lastPosition: json['last_position'] == null lastPosition: json['last_position'] == null
? null ? null
: Position.fromJson(json['last_position'] as Map<String, dynamic>), : Position.fromJson(json['last_position'] as Map<String, dynamic>),
@@ -4,7 +4,9 @@ import 'package:go_router/go_router.dart';
import '../../data/sources/ble/ble_provisioner.dart'; import '../../data/sources/ble/ble_provisioner.dart';
import '../../data/sources/local/credential_store.dart'; import '../../data/sources/local/credential_store.dart';
import '../../domain/models/sensor.dart';
import '../../providers.dart'; import '../../providers.dart';
import '../sensors/calibration_sheet.dart';
enum _Step { scan, configure, done } enum _Step { scan, configure, done }
@@ -30,6 +32,7 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
_Step _step = _Step.scan; _Step _step = _Step.scan;
BleScanResult? _selected; BleScanResult? _selected;
Sensor? _provisionedSensor;
bool _provisioning = false; bool _provisioning = false;
bool _mqttOverrideEnabled = false; bool _mqttOverrideEnabled = false;
bool _usingNewWifiConnection = true; bool _usingNewWifiConnection = true;
@@ -102,7 +105,7 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
mqttHost: mqttHost, mqttHost: mqttHost,
mqttPort: mqttPort, mqttPort: mqttPort,
); );
await ref _provisionedSensor = await ref
.read(sensorRepositoryProvider) .read(sensorRepositoryProvider)
.createSensor(_selected!.name, name: _selected!.name); .createSensor(_selected!.name, name: _selected!.name);
@@ -254,12 +257,26 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
), ),
_DoneStep( _DoneStep(
onGoToFloorPlan: () { onGoToFloorPlan: () {
final router = GoRouter.of(context); if (_provisionedSensor != null) {
Navigator.of(context).pop(); _placeOnFloorPlan(context, _provisionedSensor!);
router.go('/floorplan'); }
}, },
onAddAnother: _reset, onAddAnother: _reset,
onDone: () => Navigator.of(context).pop(), onDone: () => Navigator.of(context).pop(),
onCalibrate: _provisionedSensor == null
? null
: () {
final sensor = _provisionedSensor!;
Navigator.of(
context,
rootNavigator: true,
).pop();
showCalibrationSheet(
context,
sensor.id,
sensor.sensorId,
);
},
), ),
] ]
.map( .map(
@@ -276,6 +293,16 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
), ),
); );
} }
void _placeOnFloorPlan(BuildContext context, Sensor sensor) {
final router = GoRouter.of(context);
final fromSensors =
router.routeInformationProvider.value.uri.path == '/sensors';
ref.read(sensorPlacementProvider.notifier).state = sensor;
ref.read(sensorPlacementOriginSensorsProvider.notifier).state = fromSensors;
Navigator.of(context, rootNavigator: true).pop();
router.go('/floorplan');
}
} }
class _ScanPage extends StatefulWidget { class _ScanPage extends StatefulWidget {
@@ -491,9 +518,7 @@ class _ConfigurePageState extends State<_ConfigurePage> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
_obscurePassword _obscurePassword ? Icons.visibility_off : Icons.visibility,
? Icons.visibility_off
: Icons.visibility,
), ),
onPressed: () => onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword), setState(() => _obscurePassword = !_obscurePassword),
@@ -595,14 +620,17 @@ class _DoneStep extends StatelessWidget {
required this.onGoToFloorPlan, required this.onGoToFloorPlan,
required this.onAddAnother, required this.onAddAnother,
required this.onDone, required this.onDone,
this.onCalibrate,
}); });
final VoidCallback onGoToFloorPlan; final VoidCallback onGoToFloorPlan;
final VoidCallback onAddAnother; final VoidCallback onAddAnother;
final VoidCallback onDone; final VoidCallback onDone;
final VoidCallback? onCalibrate;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: Column( child: Column(
@@ -613,22 +641,36 @@ class _DoneStep extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Sensor provisioned!', 'Sensor provisioned!',
style: Theme.of(context).textTheme.titleLarge, style: theme.textTheme.titleLarge,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'The sensor will appear in the list once it connects to the network. ' 'The sensor will appear in the list once it connects to the network. '
'Open the floor plan to place it.', 'Open the floor plan to place it.',
style: Theme.of(context).textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
FilledButton.icon( FilledButton.icon(
icon: const Icon(Icons.map_outlined), icon: const Icon(Icons.gps_fixed),
label: const Text('Place on floor plan'), label: const Text('Place on floor plan'),
onPressed: onGoToFloorPlan, onPressed: onGoToFloorPlan,
), ),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.tune),
label: const Text('Calibrate sensor'),
onPressed: onCalibrate,
),
const SizedBox(height: 8),
Text(
'For best accuracy, calibrate after mounting the sensor in its intended position.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12), const SizedBox(height: 12),
OutlinedButton.icon( OutlinedButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
@@ -520,7 +520,7 @@ class _TrackingCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.label_outline), const Icon(Icons.label),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import 'package:vibration/vibration.dart'; import 'package:flutter/services.dart';
import '../../../domain/models/floor.dart'; import '../../../domain/models/floor.dart';
import '../../../domain/models/floor_plan_mode.dart'; import '../../../domain/models/floor_plan_mode.dart';
@@ -77,8 +77,7 @@ class FloorPlanEditorState extends State<FloorPlanEditor> {
case 'sensorTapped': case 'sensorTapped':
final id = data['id'] as String; final id = data['id'] as String;
widget.onSensorTapped(id); widget.onSensorTapped(id);
Vibration.hasVibrator() HapticFeedback.lightImpact();
.then((t) { if (t) Vibration.vibrate(duration: 20); });
case 'sensorMoved': case 'sensorMoved':
final id = data['id'] as String; final id = data['id'] as String;
if (_repositioningSensorId == id) { if (_repositioningSensorId == id) {
@@ -0,0 +1,212 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/repositories/sensor_repository.dart';
import '../../domain/models/calibration.dart';
const _waveformCapacity = 30;
class CalibrationNotifier extends StateNotifier<CalibrationState> {
CalibrationNotifier({
required this.sensorId,
required this.sensorDeviceId,
required this.repo,
}) : super(const CalibrationState());
final int sensorId;
final String sensorDeviceId;
final SensorRepository repo;
StreamSubscription<({String event, Map<String, dynamic> payload})>? _sub;
/// Joins the calibration Phoenix channel and calls the begin REST endpoint.
/// Channel subscription is set up first to guarantee no reading events are
/// dropped before the server acknowledges the stage start.
Future<void> beginCalibration() async {
_sub = repo
.calibrationEvents(sensorDeviceId)
.listen(_onEvent, onError: _onError);
try {
final samplesNeeded = await repo.beginCalibration(sensorId);
state = state.copyWith(
phase: CalibrationPhase.selectingTag,
samplesNeeded: samplesNeeded,
samplesCollected: 0,
selectedDistance: calibrationDistances.first,
completedDistances: {},
waveform: [],
clearLatestRssi: true,
clearTagRssiReadings: true,
clearSelectedTagId: true,
);
} catch (e) {
_sub?.cancel();
_sub = null;
state = state.copyWith(
phase: CalibrationPhase.error,
error: e.toString(),
);
}
}
void selectTag(String tagId) {
if (state.phase != CalibrationPhase.selectingTag) return;
state = state.copyWith(selectedTagId: tagId);
}
Future<void> commitTag() async {
final tagId = state.selectedTagId;
if (tagId == null || state.phase != CalibrationPhase.selectingTag) return;
try {
final samplesNeeded = await repo.setCalibrationTag(sensorId, tagId);
state = state.copyWith(
phase: CalibrationPhase.ready,
samplesNeeded: samplesNeeded,
);
} catch (e) {
state = state.copyWith(
phase: CalibrationPhase.error,
error: e.toString(),
);
}
}
void selectDistance(double distance) {
if (state.phase != CalibrationPhase.ready) return;
if (state.completedDistances.contains(distance)) return;
state = state.copyWith(selectedDistance: distance);
}
Future<void> startStage() async {
final distance = state.selectedDistance;
if (distance == null || state.phase != CalibrationPhase.ready) return;
state = state.copyWith(
phase: CalibrationPhase.collecting,
samplesCollected: 0,
waveform: [],
clearLatestRssi: true,
clearAvgRssi: true,
);
try {
await repo.startStage(sensorId, distance);
} catch (e) {
state = state.copyWith(
phase: CalibrationPhase.error,
error: e.toString(),
);
}
}
Future<void> finishCalibration() async {
try {
final result = await repo.finishCalibration(sensorId);
_sub?.cancel();
_sub = null;
repo.leaveCalibrationChannel(sensorDeviceId);
state = state.copyWith(
phase: CalibrationPhase.done,
resultRssiRef: result.rssiRef,
resultPathLossExp: result.pathLossExp,
);
} catch (e) {
state = state.copyWith(
phase: CalibrationPhase.error,
error: e.toString(),
);
}
}
Future<void> cancelCalibration() async {
_sub?.cancel();
_sub = null;
repo.leaveCalibrationChannel(sensorDeviceId);
try {
await repo.cancelCalibration(sensorId);
} catch (_) {
// Best-effort: ignore errors on cancel (sensor may already be idle).
}
state = const CalibrationState();
}
void _onEvent(({String event, Map<String, dynamic> payload}) msg) {
switch (msg.event) {
case 'scan_reading':
_handleScanReading(msg.payload);
case 'tag_set':
final tagId = msg.payload['tag_id'] as String;
state = state.copyWith(selectedTagId: tagId);
case 'reading':
_handleReading(msg.payload);
case 'stage_complete':
_handleStageComplete(msg.payload);
case 'cancelled':
state = const CalibrationState();
default:
break;
}
}
void _handleScanReading(Map<String, dynamic> payload) {
final tagId = payload['tag_id'] as String;
final rssi = (payload['rssi'] as num).toDouble();
state = state.copyWith(
tagRssiReadings: {...state.tagRssiReadings, tagId: rssi},
);
}
void _handleReading(Map<String, dynamic> payload) {
final rssi = (payload['rssi'] as num).toDouble();
final progress = payload['stage_progress'] as Map<String, dynamic>;
final current = progress['current'] as int;
final newWaveform = List<double>.from(state.waveform);
if (newWaveform.length >= _waveformCapacity) newWaveform.removeAt(0);
newWaveform.add(rssi);
final avg = newWaveform.reduce((a, b) => a + b) / newWaveform.length;
state = state.copyWith(
latestRssi: rssi,
samplesCollected: current,
waveform: newWaveform,
avgRssi: avg,
);
}
void _handleStageComplete(Map<String, dynamic> payload) {
final distance = (payload['distance'] as num).toDouble();
final meanRssi = (payload['mean_rssi'] as num).toDouble();
final completed = {...state.completedDistances, distance};
final nextDistance = calibrationDistances
.where((d) => !completed.contains(d))
.firstOrNull;
HapticFeedback.mediumImpact();
state = state.copyWith(
phase: CalibrationPhase.ready,
completedDistances: completed,
avgRssi: meanRssi,
selectedDistance: nextDistance,
samplesCollected: 0,
waveform: [],
);
}
void _onError(Object error) {
state = state.copyWith(
phase: CalibrationPhase.error,
error: error.toString(),
);
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
}
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../domain/models/sensor.dart'; import '../../domain/models/sensor.dart';
import '../../providers.dart'; import '../../providers.dart';
import 'calibration_sheet.dart';
void showSensorDetailSheet(BuildContext context, int sensorId) { void showSensorDetailSheet(BuildContext context, int sensorId) {
showModalBottomSheet<void>( showModalBottomSheet<void>(
@@ -89,6 +90,7 @@ class _SensorDetailSheetState extends ConsumerState<SensorDetailSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sensorAsync = ref.watch(sensorProvider(widget.sensorId)); final sensorAsync = ref.watch(sensorProvider(widget.sensorId));
final roomsAsync = ref.watch(roomsProvider); final roomsAsync = ref.watch(roomsProvider);
final versionAsync = ref.watch(sensorVersionProvider(widget.sensorId));
return sensorAsync.when( return sensorAsync.when(
loading: () => const SizedBox( loading: () => const SizedBox(
@@ -110,9 +112,12 @@ class _SensorDetailSheetState extends ConsumerState<SensorDetailSheet> {
?.name, ?.name,
); );
final version = versionAsync.whenOrNull(data: (v) => v);
return _SheetBody( return _SheetBody(
sensor: sensor, sensor: sensor,
roomName: roomName, roomName: roomName,
version: version,
editing: _editing, editing: _editing,
nameCtrl: _nameCtrl, nameCtrl: _nameCtrl,
onEditToggle: () => setState(() { onEditToggle: () => setState(() {
@@ -121,6 +126,10 @@ class _SensorDetailSheetState extends ConsumerState<SensorDetailSheet> {
}), }),
onSaveName: () => _saveName(sensor), onSaveName: () => _saveName(sensor),
onPlace: () => _placeOnFloorPlan(context, sensor), onPlace: () => _placeOnFloorPlan(context, sensor),
onCalibrate: () {
Navigator.of(context, rootNavigator: true).pop();
showCalibrationSheet(context, sensor.id, sensor.sensorId);
},
onDelete: () => _delete(context, sensor), onDelete: () => _delete(context, sensor),
); );
}, },
@@ -132,21 +141,25 @@ class _SheetBody extends StatelessWidget {
const _SheetBody({ const _SheetBody({
required this.sensor, required this.sensor,
required this.roomName, required this.roomName,
required this.version,
required this.editing, required this.editing,
required this.nameCtrl, required this.nameCtrl,
required this.onEditToggle, required this.onEditToggle,
required this.onSaveName, required this.onSaveName,
required this.onPlace, required this.onPlace,
required this.onCalibrate,
required this.onDelete, required this.onDelete,
}); });
final Sensor sensor; final Sensor sensor;
final String? roomName; final String? roomName;
final String? version;
final bool editing; final bool editing;
final TextEditingController nameCtrl; final TextEditingController nameCtrl;
final VoidCallback onEditToggle; final VoidCallback onEditToggle;
final VoidCallback onSaveName; final VoidCallback onSaveName;
final VoidCallback onPlace; final VoidCallback onPlace;
final VoidCallback onCalibrate;
final VoidCallback onDelete; final VoidCallback onDelete;
@override @override
@@ -210,6 +223,7 @@ class _SheetBody extends StatelessWidget {
? '(${sensor.x!.toStringAsFixed(2)}, ${sensor.y!.toStringAsFixed(2)})' ? '(${sensor.x!.toStringAsFixed(2)}, ${sensor.y!.toStringAsFixed(2)})'
: 'Not placed', : 'Not placed',
), ),
_InfoRow(label: 'Firmware', value: version ?? ''),
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton.icon( FilledButton.icon(
icon: Icon(Icons.my_location), icon: Icon(Icons.my_location),
@@ -219,6 +233,12 @@ class _SheetBody extends StatelessWidget {
onPressed: onPlace, onPressed: onPlace,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
OutlinedButton.icon(
icon: const Icon(Icons.tune),
label: const Text('Calibrate'),
onPressed: onCalibrate,
),
const SizedBox(height: 8),
OutlinedButton.icon( OutlinedButton.icon(
icon: const Icon(Icons.bluetooth), icon: const Icon(Icons.bluetooth),
label: const Text('Reprovision'), label: const Text('Reprovision'),
+10 -16
View File
@@ -178,7 +178,7 @@ class _TagDetailSheetState extends ConsumerState<TagDetailSheet> {
value: _formatLastSeen(tag.lastSeen!), value: _formatLastSeen(tag.lastSeen!),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton.tonal( OutlinedButton.icon(
onPressed: () { onPressed: () {
final isTracking = trackedTag?.tagId == tag.tagId; final isTracking = trackedTag?.tagId == tag.tagId;
ref.read(trackedTagProvider.notifier).state = isTracking ref.read(trackedTagProvider.notifier).state = isTracking
@@ -189,21 +189,15 @@ class _TagDetailSheetState extends ConsumerState<TagDetailSheet> {
GoRouter.of(context).go('/floorplan'); GoRouter.of(context).go('/floorplan');
} }
}, },
child: Row( icon: Icon(
mainAxisAlignment: MainAxisAlignment.center, trackedTag?.tagId == tag.tagId
children: [ ? Icons.blur_off
Icon( : Icons.blur_on,
trackedTag?.tagId == tag.tagId ),
? Icons.radar label: Text(
: Icons.radar_outlined, trackedTag?.tagId == tag.tagId
), ? 'Hide particle cloud'
const SizedBox(width: 8), : 'View particle cloud',
Text(
trackedTag?.tagId == tag.tagId
? 'Hide particle cloud'
: 'View particle cloud',
),
],
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
+22
View File
@@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'domain/models/server_config.dart'; import 'domain/models/server_config.dart';
import 'domain/models/sensor.dart'; import 'domain/models/sensor.dart';
import 'domain/models/calibration.dart';
import 'features/sensors/calibration_notifier.dart';
import 'domain/models/floor.dart'; import 'domain/models/floor.dart';
import 'domain/models/tag.dart'; import 'domain/models/tag.dart';
import 'domain/models/particle.dart'; import 'domain/models/particle.dart';
@@ -145,6 +147,11 @@ final sensorProvider =
return ref.watch(sensorRepositoryProvider).getSensor(id); return ref.watch(sensorRepositoryProvider).getSensor(id);
}); });
final sensorVersionProvider =
FutureProvider.autoDispose.family<String?, int>((ref, id) {
return ref.watch(sensorRepositoryProvider).getVersion(id);
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tag data // Tag data
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -203,3 +210,18 @@ final particleSnapshotProvider =
StreamProvider.autoDispose.family<ParticleSnapshot, String>((ref, tagId) { StreamProvider.autoDispose.family<ParticleSnapshot, String>((ref, tagId) {
return ref.watch(tagRepositoryProvider).watchParticleCloud(tagId); return ref.watch(tagRepositoryProvider).watchParticleCloud(tagId);
}); });
// ---------------------------------------------------------------------------
// Calibration state - keyed by sensor DB id
// ---------------------------------------------------------------------------
/// Interactive calibration state machine for a specific sensor.
/// Auto-disposed when no widget is watching (e.g. after the sheet closes).
final calibrationProvider = StateNotifierProvider.autoDispose
.family<CalibrationNotifier, CalibrationState, ({int id, String deviceId})>(
(ref, sensor) => CalibrationNotifier(
sensorId: sensor.id,
sensorDeviceId: sensor.deviceId,
repo: ref.watch(sensorRepositoryProvider),
),
);