Skip to content

Commit 995adbf

Browse files
feat: Implement MAX30102 sensor (#3320)
Co-authored-by: Marc Nause <marc.nause@audioattack.de>
1 parent 62b6dcb commit 995adbf

6 files changed

Lines changed: 656 additions & 0 deletions

File tree

assets/images/max30102.png

57.4 KB
Loading
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import 'package:pslab/communication/peripherals/i2c.dart';
2+
import 'package:pslab/communication/science_lab.dart';
3+
import 'package:pslab/others/logger_service.dart';
4+
5+
import '../../l10n/app_localizations.dart';
6+
import '../../providers/locator.dart';
7+
8+
class MAX30102 {
9+
AppLocalizations get appLocalizations => getIt.get<AppLocalizations>();
10+
11+
static const String tag = "MAX30102";
12+
13+
static const int address = 0x57;
14+
15+
static const int intStatus1 = 0x00;
16+
static const int intStatus2 = 0x01;
17+
static const int intEnable1 = 0x02;
18+
static const int intEnable2 = 0x03;
19+
static const int fifoWritePtr = 0x04;
20+
static const int overflowCounter = 0x05;
21+
static const int fifoReadPtr = 0x06;
22+
static const int fifoData = 0x07;
23+
static const int fifoConfig = 0x08;
24+
static const int modeConfig = 0x09;
25+
static const int spo2Config = 0x0A;
26+
static const int led1Pa = 0x0C;
27+
static const int led2Pa = 0x0D;
28+
29+
static const int numPlots = 2;
30+
static const List<String> plotNames = ["Red Absorptance", "IR Absorptance"];
31+
32+
final I2C i2c;
33+
int redValue = 0;
34+
int irValue = 0;
35+
36+
MAX30102._(this.i2c);
37+
38+
static Future<MAX30102> create(I2C i2c, ScienceLab scienceLab) async {
39+
final max30102 = MAX30102._(i2c);
40+
await max30102._initializeSensor(scienceLab);
41+
return max30102;
42+
}
43+
44+
Future<void> _initializeSensor(ScienceLab scienceLab) async {
45+
if (!scienceLab.isConnected()) {
46+
throw Exception("ScienceLab not connected");
47+
}
48+
49+
try {
50+
await i2c.write(address, [0x40], modeConfig);
51+
await Future.delayed(const Duration(milliseconds: 100));
52+
53+
await i2c.write(address, [0x03], modeConfig);
54+
55+
await i2c.write(address, [0x27], spo2Config);
56+
57+
await i2c.write(address, [0x24], led1Pa);
58+
await i2c.write(address, [0x24], led2Pa);
59+
60+
logger.d("MAX30102 Initialized successfully");
61+
} catch (e) {
62+
logger.e("Error initializing MAX30102 sensor registers: $e");
63+
rethrow;
64+
}
65+
}
66+
67+
Future<void> readRawFifo() async {
68+
try {
69+
List<int> data = await i2c.readBulk(address, fifoData, 6);
70+
71+
if (data.length < 6) {
72+
throw Exception(
73+
"Expected 6 bytes but got ${data.length} from FIFO data register");
74+
}
75+
redValue = ((data[0] << 16) | (data[1] << 8) | data[2]) & 0x03FFFF;
76+
irValue = ((data[3] << 16) | (data[4] << 8) | data[5]) & 0x03FFFF;
77+
} catch (e) {
78+
logger.e("Error reading raw FIFO data: $e");
79+
rethrow;
80+
}
81+
}
82+
83+
Future<Map<String, double>> getRawData() async {
84+
try {
85+
await readRawFifo();
86+
87+
return {
88+
'red': redValue.toDouble(),
89+
'ir': irValue.toDouble(),
90+
};
91+
} catch (e) {
92+
logger.e("Error getting raw map data: $e");
93+
rethrow;
94+
}
95+
}
96+
}

lib/l10n/app_en.arb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,12 @@
739739
"acceleration" : "Acceleration",
740740
"angularVelocity" : "Angular Velocity",
741741
"temp" : "Temp",
742+
"adcEstimateLabel" : "*Values are algorithmic estimates based on raw ADC.",
743+
"metrics" : "Metrics",
744+
"max30102" : "MAX30102",
745+
"estBMP" : "Est. BPM",
746+
"estSpo2" : "SpO2 %",
747+
"imgMissing" : "Img Missing",
742748
"error" : "Error",
743749
"loading" : "Loading...",
744750
"unknown" : "Unknown",
@@ -748,6 +754,7 @@
748754
"sensorDescCCS811": "eCO2 & TVOC Indoor Air Quality",
749755
"sensorDescHMC5883L": "3-Axis Digital Magnetometer Compass",
750756
"sensorDescMLX90614": "Non-Contact Infrared Temperature",
757+
"sensorDescMAX30102": "Pulse Oximeter & Heart-Rate ",
751758
"sensorDescMPU6050": "6-Axis Accelerometer & Gyroscope",
752759
"sensorDescMPU925X": "9-Axis Motion Tracking (Accel/Gyro/Mag)",
753760
"sensorDescSHT21": "Digital Humidity & Temperature",
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import 'dart:async';
2+
import 'dart:math';
3+
import 'package:flutter/material.dart';
4+
import 'package:pslab/communication/peripherals/i2c.dart';
5+
import 'package:pslab/communication/science_lab.dart';
6+
import 'package:pslab/communication/sensors/max30102.dart';
7+
import 'package:pslab/constants.dart';
8+
import 'package:pslab/others/logger_service.dart';
9+
import 'package:pslab/models/chart_data_points.dart';
10+
11+
class MAX30102Provider extends ChangeNotifier {
12+
MAX30102? _sensor;
13+
bool _isInitialized = false;
14+
15+
bool isRunning = false;
16+
bool isLooping = true;
17+
18+
int _timegapMs = 200;
19+
int get timegapMs => _timegapMs < 200 ? 200 : _timegapMs;
20+
21+
int numberOfReadings = 100;
22+
int _currentStep = 0;
23+
Timer? _timer;
24+
25+
double _redValue = 0.0;
26+
double _irValue = 0.0;
27+
double get redValue => _redValue;
28+
double get irValue => _irValue;
29+
30+
int _calculatedBPM = 0;
31+
int _calculatedSpO2 = 0;
32+
int get calculatedBPM => _calculatedBPM;
33+
int get calculatedSpO2 => _calculatedSpO2;
34+
35+
List<ChartDataPoint> redData = [];
36+
List<ChartDataPoint> irData = [];
37+
38+
List<ChartDataPoint> bpmData = [];
39+
List<ChartDataPoint> spo2Data = [];
40+
41+
Future<void> initializeSensors({
42+
required Function(String) onError,
43+
I2C? i2c,
44+
ScienceLab? scienceLab,
45+
}) async {
46+
if (i2c == null || scienceLab == null) {
47+
onError(appLocalizations.notConnected);
48+
return;
49+
}
50+
try {
51+
_sensor = await MAX30102.create(i2c, scienceLab);
52+
_isInitialized = true;
53+
notifyListeners();
54+
} catch (e) {
55+
logger.e("Error initializing MAX30102: $e");
56+
onError(e.toString());
57+
}
58+
}
59+
60+
void toggleDataCollection() {
61+
if (isRunning) {
62+
stopDataCollection();
63+
} else {
64+
startDataCollection();
65+
}
66+
}
67+
68+
void startDataCollection() {
69+
if (!_isInitialized || _sensor == null) return;
70+
71+
isRunning = true;
72+
_timer = Timer.periodic(Duration(milliseconds: timegapMs), (timer) {
73+
_fetchData();
74+
});
75+
76+
notifyListeners();
77+
}
78+
79+
void stopDataCollection() {
80+
isRunning = false;
81+
_timer?.cancel();
82+
notifyListeners();
83+
}
84+
85+
Future<void> _fetchData() async {
86+
try {
87+
var data = await _sensor!.getRawData();
88+
_redValue = data['red'] ?? 0.0;
89+
_irValue = data['ir'] ?? 0.0;
90+
91+
if (_redValue >= 262140 || _irValue >= 262140) {
92+
return;
93+
}
94+
95+
redData.add(ChartDataPoint(_currentStep.toDouble(), _redValue));
96+
irData.add(ChartDataPoint(_currentStep.toDouble(), _irValue));
97+
98+
if (redData.length > numberOfReadings) {
99+
redData.removeAt(0);
100+
irData.removeAt(0);
101+
}
102+
103+
_calculateMetrics();
104+
105+
bpmData.add(
106+
ChartDataPoint(_currentStep.toDouble(), _calculatedBPM.toDouble()));
107+
spo2Data.add(
108+
ChartDataPoint(_currentStep.toDouble(), _calculatedSpO2.toDouble()));
109+
110+
if (bpmData.length > numberOfReadings) {
111+
bpmData.removeAt(0);
112+
spo2Data.removeAt(0);
113+
}
114+
115+
_currentStep++;
116+
117+
if (!isLooping && _currentStep >= numberOfReadings) {
118+
stopDataCollection();
119+
}
120+
121+
notifyListeners();
122+
} catch (e) {
123+
logger.e("Error fetching MAX30102 data: $e");
124+
}
125+
}
126+
127+
void _calculateMetrics() {
128+
int requiredSamples = numberOfReadings ~/ 4;
129+
if (irData.length < requiredSamples) {
130+
logger.d(
131+
"Buffer filling: ${irData.length}/$requiredSamples before math starts.");
132+
return;
133+
}
134+
135+
if (_irValue < 50000) {
136+
logger.d(" No finger detected. IR Value: ${_irValue.toInt()}");
137+
_calculatedBPM = 0;
138+
_calculatedSpO2 = 0;
139+
return;
140+
}
141+
142+
try {
143+
double dcRed =
144+
redData.map((e) => e.y).reduce((a, b) => a + b) / redData.length;
145+
double dcIr =
146+
irData.map((e) => e.y).reduce((a, b) => a + b) / irData.length;
147+
148+
double maxRed = redData.map((e) => e.y).reduce(max);
149+
double minRed = redData.map((e) => e.y).reduce(min);
150+
double acRed = maxRed - minRed;
151+
152+
double maxIr = irData.map((e) => e.y).reduce(max);
153+
double minIr = irData.map((e) => e.y).reduce(min);
154+
double acIr = maxIr - minIr;
155+
156+
if (dcRed > 0 && dcIr > 0 && acRed > 0 && acIr > 0) {
157+
double ratio = (acRed / dcRed) / (acIr / dcIr);
158+
double spo2 = 110.0 - (25.0 * ratio);
159+
_calculatedSpO2 = spo2.clamp(70.0, 99.0).toInt();
160+
}
161+
162+
int peakCount = 0;
163+
int lastPeakIndex = -1;
164+
165+
for (int i = 1; i < irData.length - 1; i++) {
166+
if (irData[i].y > irData[i - 1].y && irData[i].y > irData[i + 1].y) {
167+
if (lastPeakIndex == -1 || (i - lastPeakIndex) >= 2) {
168+
peakCount++;
169+
lastPeakIndex = i;
170+
}
171+
}
172+
}
173+
174+
double timeWindowMinutes = (irData.length * timegapMs) / 60000.0;
175+
int newBpm = 0;
176+
if (timeWindowMinutes > 0) {
177+
newBpm = (peakCount / timeWindowMinutes).toInt();
178+
179+
if (newBpm >= 40 && newBpm <= 180) {
180+
_calculatedBPM = newBpm;
181+
logger.i("BPM ACCEPTED: $_calculatedBPM");
182+
}
183+
}
184+
} catch (e) {
185+
logger.e("DSP Error: $e");
186+
}
187+
}
188+
189+
void toggleLooping() {
190+
isLooping = !isLooping;
191+
notifyListeners();
192+
}
193+
194+
void setTimegap(int gap) {
195+
_timegapMs = gap < 200 ? 200 : gap;
196+
notifyListeners();
197+
}
198+
199+
void setNumberOfReadings(int num) {
200+
numberOfReadings = num;
201+
notifyListeners();
202+
}
203+
204+
void clearData() {
205+
redData.clear();
206+
irData.clear();
207+
bpmData.clear();
208+
spo2Data.clear();
209+
_currentStep = 0;
210+
_redValue = 0.0;
211+
_irValue = 0.0;
212+
_calculatedBPM = 0;
213+
_calculatedSpO2 = 0;
214+
notifyListeners();
215+
}
216+
217+
@override
218+
void dispose() {
219+
_timer?.cancel();
220+
super.dispose();
221+
}
222+
}

0 commit comments

Comments
 (0)