diff --git a/assets/blue-water-background.jpg b/assets/blue-water-background.jpg new file mode 100644 index 0000000..9273273 Binary files /dev/null and b/assets/blue-water-background.jpg differ diff --git a/components/DeviceHttpDataSource.js b/components/DeviceHttpDataSource.js index 72697c4..1536f82 100644 --- a/components/DeviceHttpDataSource.js +++ b/components/DeviceHttpDataSource.js @@ -1,48 +1,58 @@ import React from 'react'; import PropTypes from 'prop-types'; -import msgpack5 from 'msgpack5'; - - -/* -config = { - peakDetector: 'simple', - peakDetectorConfig: {'threshold': 3000}, - - deviceUrl: 'http://smartswim', - peaksPerLap: 30, - forceAverage -} -*/ +import * as msgpack from 'msgpack-lite'; class DeviceHttpDataSource extends React.Component { constructor(props) { super(props); this.data = []; - this.dataUrl = new URL("/api/session/data", this.props.deviceUrl); + this.dataUrl = this.props.deviceUrl + "/api/session/data"; // msgpack setup - this.msgpack = msgpack5(); - this.msgpack.registerDecoder(205, function (byteArr) { + this.msgpackCodec = msgpack.createCodec(); + this.msgpackCodec.addExtUnpacker(205, function (byteArr) { const buffer = byteArr.buffer.slice(byteArr.byteOffset, byteArr.byteLength + byteArr.byteOffset); - return new Int16Array(buffer); + const result = new Int16Array(buffer); + return result; }); this.fetchDataHttp = this.fetchDataHttp.bind(this); } + + getUrl(url) { + return new Promise((accept, reject) => { + var req = new XMLHttpRequest(); + req.open("GET", url, true); + req.responseType = "arraybuffer"; + + req.onload = function (event) { + var resp = req.response; + if (resp) { + accept(resp); + } + }; + req.send(null); + }); + //todo reject on error + } + async fetchDataHttp() { - this.dataUrl.searchParams.set("startIdx", this.data.length); - const response = await fetch(this.dataUrl); - const arrayBuffer = await response.arrayBuffer(); - const decoded = this.msgpack.decode(arrayBuffer); - const typedValueArr = decoded['values']; - console.log("new data inside fetch", typedValueArr); - const newDataStart = this.data.length; - for (let i = 0; i < typedValueArr.length; ++i) { - this.data.push(typedValueArr[i]); + try { + const url = this.dataUrl + "?startIdx=" + this.data.length; + const arrayBuffer = await this.getUrl(url); + const decoded = msgpack.decode(new Uint8Array(arrayBuffer), { codec: this.msgpackCodec }); + + const typedValueArr = decoded['values']; + const newDataStart = this.data.length; + for (let i = 0; i < typedValueArr.length; ++i) { + this.data.push(typedValueArr[i]); + } + this.props.onNewData(this.data, newDataStart); + } catch (err) { + //console.log(err); } - this.props.onNewData(this.data, newDataStart); } componentDidMount() { @@ -55,7 +65,7 @@ class DeviceHttpDataSource extends React.Component { } render() { - return ""; + return null; } } diff --git a/components/Graph.js b/components/Graph.js index 4111e55..3013780 100644 --- a/components/Graph.js +++ b/components/Graph.js @@ -1,7 +1,8 @@ import React from 'react'; import {View, StyleSheet, Text} from 'react-native'; -import Svg, {Polyline, Polygon, Rect, G} from 'react-native-svg-web'; +//import Svg, {Polyline, Polygon, Rect, G} from 'react-native-svg-web'; +import Svg, {Polyline, Polygon, Rect, G} from 'react-native-svg'; const Graph = props => { @@ -11,7 +12,6 @@ const Graph = props => { const coordStr = data.map((element, i) => `${i}, ${element / 2}`); - return ( diff --git a/components/HomeView.js b/components/HomeView.js new file mode 100644 index 0000000..2a7c275 --- /dev/null +++ b/components/HomeView.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { Content, Card, CardItem, Body, Text, Button } from 'native-base'; +import { Image, ScrollView } from 'react-native'; +import { connect } from 'react-redux'; +import { startSession} from '../state/ActionCreators'; + +function HomeView(props) { + const buttonText = props.running ? "View Swim Session" : "Start swimming"; + + const onButtonPress = () => { + if(!props.running) { + props.dispatch(startSession()); + } + props.navigation.navigate('Training') + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +const mapStateToProps = (state) => { + return { running: state.session.running }; +}; + +export default connect(mapStateToProps)(HomeView); diff --git a/components/IconCard.js b/components/IconCard.js index 4e2c968..4f58635 100644 --- a/components/IconCard.js +++ b/components/IconCard.js @@ -6,13 +6,14 @@ const IconCard = props => { return ( - + + + {props.value} + + {props.label} - - {props.value} - ); }; @@ -29,7 +30,8 @@ const styles = StyleSheet.create({ }); IconCard.defaultProps = { - fontSize: 85 + fontSize: 85, + flex: 1 }; export default IconCard; diff --git a/components/LiveTrainingView.js b/components/LiveTrainingView.js new file mode 100644 index 0000000..b68bd5a --- /dev/null +++ b/components/LiveTrainingView.js @@ -0,0 +1,111 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Container, Text, Header, Content, Left, Body, Right, Button, Icon, Title, Card, CardItem, Fab} from 'native-base'; +import { LinearGradient } from 'expo-linear-gradient'; +import IconCard from './IconCard'; +import Graph from './Graph'; +import DeviceHttpDataSource from './DeviceHttpDataSource'; +import { PeakDetectorSimple } from '../data_processing/PeakDetection'; + + + +export default class LiveTrainingView extends React.Component { + + constructor(props) { + super(props); + this.state = { + isReady: false, + themeNumber: 0, + numPeaks: 0, + numLaps: 0, + measurements: [] + }; + + this.config = { + deviceUrl: "http://smartswim", + peakThreshold: 30, + peaksPerLap: 30, + updateInterval: 3000, + }; + + this.peakDetector = new PeakDetectorSimple(this.config.peakThreshold, peaks => { + //console.log("peaks:", peaks.length); + this.setState({ + numPeaks: peaks.length, + numLaps: (peaks.length / this.config.peaksPerLap).toFixed(1) + }); + }); + }; + + handleStart = () => { + fetch(this.config.deviceUrl + "/api/session/start").catch(err => console.log(err)); + } + + handleStop = () => { + fetch(this.config.deviceUrl + "/api/session/stop").catch(err => console.log(err)); + } + + handleThemeChange = () => { + this.setState((state, props) => { return { themeNumber: ((state.themeNumber + 1) % themeArray.length) } }) + } + + handleNewData = (fullData, newDataStart) => { + const newData = fullData.slice(newDataStart); + //console.log("New data", newData.length, "Full data", fullData.length, "new data start", newDataStart); + //console.log("new data", newData); + this.peakDetector.addVector(newData); + this.setState({ measurements: fullData }); + } + + render() { + return ( + + + + + {/* + + + + + */} + + + + ); + } + +} + + +const backgroundColors = { + 'hot': ['#830e5f', '#fd5139'], + 'darkBlue': ['#4265a3', '#cfada7'], + 'lightBlue': ['#50a4db', '#74bbe2'], + 'foggy': ['#bc8db8', '#5d5e90'], +}; + +const themeArray = [ + 'hot', 'darkBlue', 'lightBlue', 'foggy' +]; + +const styles = StyleSheet.create({ + card: { + flexDirection: 'row', + backgroundColor: 'rgba(0, 0, 0, 0.2)', + margin: 5, + padding: 5, + borderRadius: 3, + justifyContent: 'space-between', + /* + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.18, + shadowRadius: 1.00, + + elevation: 1,*/ + } +}); diff --git a/components/ThemedStackNavigation.js b/components/ThemedStackNavigation.js new file mode 100644 index 0000000..e04e941 --- /dev/null +++ b/components/ThemedStackNavigation.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { LinearGradient } from 'expo-linear-gradient'; +import { NavigationContainer } from '@react-navigation/native'; +import { createStackNavigator } from '@react-navigation/stack'; +import { BlurView } from 'expo-blur'; +import { StyleSheet } from 'react-native'; +import backgroundColors from './Themes'; + +// Own views +import LiveTrainingView from './LiveTrainingView'; +import HomeView from './HomeView'; +import { connect } from 'react-redux'; + + +const Stack = createStackNavigator(); + + +function ThemedStackNavigation(props) { + + const screenOptions = { + cardStyle: { + backgroundColor: "transparent", + opacity: 1 + }, + headerTransparent: "true", + headerTitleStyle: { + color: 'white', + fontWeight: 'bold', + fontSize: "1.5em", + }, + headerTintColor: "white", + headerBackground: () => ( + + ), + } + + return ( + + + + + + + + + ) +}; + +const mapStateToProps = (state) => { + return { themeName: state.settings.theme }; +}; + +export default connect(mapStateToProps)(ThemedStackNavigation); diff --git a/components/Themes.js b/components/Themes.js new file mode 100644 index 0000000..3280e0d --- /dev/null +++ b/components/Themes.js @@ -0,0 +1,9 @@ + +const backgroundColors = { + 'hot': ['#830e5f', '#fd5139'], + 'darkBlue': ['#4265a3', '#cfada7'], + 'lightBlue': ['#50a4db', '#74bbe2'], + 'foggy': ['#bc8db8', '#5d5e90'], +}; + +export default backgroundColors; \ No newline at end of file diff --git a/data_processing/DataProcessing.js b/data_processing/DataProcessing.js new file mode 100644 index 0000000..bebc965 --- /dev/null +++ b/data_processing/DataProcessing.js @@ -0,0 +1,129 @@ +import DeviceHttpDataSource from './DeviceDataSource'; +import { List } from 'immutable'; +import { reportDeviceData } from '../state/ActionCreators'; +import { PeakDetectorSimple} from './PeakDetection'; + + +// todo: put in settings? +const NUM_MEASUREMENTS_PER_SECOND = 10; +const WINDOW_SIZE_SECS = 5; + +// This is the main data processing entry point, coupling between device and redux store +// - periodically fetch data +// - feeds them to analysis, (manages analysis classes) +// - adds them to redux store +class DataProcessing { + constructor(reduxStore) { + this.store = reduxStore; + this.store.subscribe(this.onStateChange); + this.state = this.store.getState(); + this.dataSource = null; + //console.log("state", this.state); + console.assert(this.state.session.running === false, "Created DataProcessing with running=True"); + this.onDataSourceChanged(this.state.settings.deviceURL); + + this.rawMeasurements = List(); + this.sessionStartTime = 0; + + this.peakDetectorSimple = new PeakDetectorSimple(this.state.settings.peakDetectorSimpleThreshold); + this.peaks = List(); + } + + onStateChange = () => { + const newState = this.store.getState(); + //console.log("DataProcessing state change", this.state, newState); + if (newState.settings.deviceURL !== this.state.settings.deviceURL) + this.onDataSourceChanged(newState.settings.deviceURL); + + if (newState.session.running && !this.state.session.running) { + this.onRunningChanged(newState.session.running); + }; + if(newState.settings.peakDetectorSimpleThreshold !== this.state.settings.peakDetectorSimpleThreshold) { + this.peakDetectorSimple = new PeakDetectorSimple(newState.settings.peakDetectorSimpleThreshold, this.onNewPeak); + this.peaks = List(this.peakDetectorSimple.addVector(this.rawMeasurements)); + }; + this.state = newState; + } + + onDataSourceChanged = (newDeviceURL) => { + if (this.dataSource !== null) { + this.dataSource.stop(); + this.dataSource = null; + } + this.dataSource = new DeviceHttpDataSource(this.newDeviceURL + "/api/session/data", this.onNewData); + } + + onRunningChanged = (running, deviceURL) => { + if (running) { + //console.log("Starting session"); + let req = new XMLHttpRequest(); + req.open("GET", deviceURL + "/api/session/start"); + this.dataSource.startIndex = 0; + this.dataSource.start(); + } else { + //console.log("Stopping session"); + req.open("GET", deviceURL + "/api/session/stop"); + this.dataSource.stop(); + this.dataSource.startIndex = 0; + } + } + + onNewData = (data) => { + data.values; + data.sessionStartTime; + data.startIndex; + let success = false; + if (data.sessionStartTime === this.sessionStartTime && data.startIndex === this.rawMeasurements.length) { + // normal case, add received data to measurement array + this.rawMeasurements.concat(List(data.values)); + this.analyzeNewMeasurements(data.startIndex); + success = true; + } + else if (data.startIndex === 0) { + // new start + this.sessionStartTime = data.sessionStartTime; + this.rawMeasurements = List(data.values); + success = true; + } else { + // missed some data -> re-query + this.dataSource.startIndex = 0; + this.sessionStartTime = 0; + //console.log("Problem: got non-consequtive data. Received:", data, + // "Own state ", { startTime: this.sessionStartTime, values: this.rawMeasurements }); + } + + if (success) { + const analysis = this.analyzeNewMeasurements(data.startIndex); + this.store.dispatch(reportDeviceData(this.sessionStartTime, data.startIndex, this.rawMeasurements, analysis)); + } + + } + + analyzeNewMeasurements = (newDataStartIdx) => { + const newPeaks = this.peakDetectorSimple.addVector(this.rawMeasurements.slice(newDataStartIdx)); + this.peaks = this.peaks.concat(List(newPeaks)); + const totalMomentum = this.rawMeasurements.reduce((sum, x) => sum + x, 0); + const peakMax = this.rawMeasurements.reduce((running, x) => max(x, running), 0); + + // windowed quantities + const windowSizeMeasurements = WINDOW_SIZE_SECS * NUM_MEASUREMENTS_PER_SECOND; + const windowedSeq = this.rawMeasurements.slice(-windowSizeMeasurements); + const peakMaxWindow = windowedSeq.reduce((running, x) => max(x, running), 0); + const momentumWindow = windowedSeq.reduce((sum, x) => sum + x, 0); + + return { + peaks: this.peaks, + totalTime: this.rawMeasurements.length / NUM_MEASUREMENTS_PER_SECOND, + activeTime: 0, + totalMomentum: totalMomentum, + peakFrequency: 0, + peakMax: peakMax, + // windowed quantities + momentumWindow: momentumWindow, + frequencyWindow: 0, + peakMaxWindow: peakMaxWindow, + } + }; +}; + +export default DataProcessing; \ No newline at end of file diff --git a/data_processing/DeviceDataSource.js b/data_processing/DeviceDataSource.js new file mode 100644 index 0000000..86aa5ff --- /dev/null +++ b/data_processing/DeviceDataSource.js @@ -0,0 +1,74 @@ +import * as msgpack from 'msgpack-lite'; + +class DeviceHttpDataSource { + + constructor(dataUrl, onNewData, pollInterval=1000, startIndex = 0) { + this.dataUrl = dataUrl; + this.onNewData = onNewData; + this.pollInterval = pollInterval; + this.startIndex = startIndex; + + // msgpack setup + this.msgpackCodec = msgpack.createCodec(); + this.msgpackCodec.addExtUnpacker(205, function (byteArr) { + const buffer = byteArr.buffer.slice(byteArr.byteOffset, byteArr.byteLength + byteArr.byteOffset); + const result = new Int16Array(buffer); + return result; + }); + + this.fetchDataHttp = this.fetchDataHttp.bind(this); + } + + getUrl(url) { + return new Promise((accept, reject) => { + var req = new XMLHttpRequest(); + req.open("GET", url, true); + req.responseType = "arraybuffer"; + + req.onload = function (event) { + var resp = req.response; + if (resp) { + accept(resp); + } + }; + req.addEventListener("error", evt => reject(evt)); + req.addEventListener("abort", evt => reject(evt)); + + req.send(null); + }); + } + + async fetchDataHttp() { + try { + const url = this.dataUrl + "?startIdx=" + this.startIndex; + const arrayBuffer = await this.getUrl(url); + const decoded = msgpack.decode(new Uint8Array(arrayBuffer), { codec: this.msgpackCodec }); + this.startIndex += decoded["values"].length; + //"values", "sessionStartTime", "startIndex" + this.onNewData(decoded); + } catch (err) { + //console.log(err); + } + } + + start() { + if (this.timer === null) { + this.timer = setInterval(this.fetchDataHttp, this.pollInterval); + return true; + } else { + return false; + } + } + + stop() { + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + return true; + } else { + return false; + } + } +}; + +export default DeviceHttpDataSource; diff --git a/data-analysis/PeakDetection.js b/data_processing/PeakDetection.js similarity index 92% rename from data-analysis/PeakDetection.js rename to data_processing/PeakDetection.js index caf6242..d46d5ea 100644 --- a/data-analysis/PeakDetection.js +++ b/data_processing/PeakDetection.js @@ -9,7 +9,6 @@ */ class PeakDetectorSimple { constructor(threshold, handleNewPeaks) { - this.peaks = []; this._threshold = threshold; this._queue = []; this._last_min = 0; @@ -17,20 +16,25 @@ class PeakDetectorSimple { this._handleNewPeaks = handleNewPeaks; } + getThreshold() { + return this._threshold; + } + addVector(vec) { + let result = []; const callbackBackup = this._handleNewPeaks; - const numPeaksBefore = this.peaks.length; this._handleNewPeaks = null; for (let i = 0; i < vec.length; ++i) { - this.add(vec[i]); + const res = this.add(vec[i]); + if(res !== null) + result.push(res); } this._handleNewPeaks = callbackBackup; - if (numPeaksBefore != this.peaks.length) { - this._handleNewPeaks(this.peaks); - } + return result; } add(value) { + let result = null; this._queue.push(value); if (this._queue.length > 3) { this._queue.shift(); @@ -41,14 +45,11 @@ class PeakDetectorSimple { const [last, current, next] = this._queue; const is_maximum = current > next && current > last; if (is_maximum && (current - this._last_min) > this._threshold) { - this.peaks.push(this._counter); - this._last_min = current; - if (this._handleNewPeaks) { - this._handleNewPeaks(this.peaks); - } + result = this._counter; } this._last_min = Math.min(this._last_min, current); this._counter += 1; + return result; } } diff --git a/state/ActionCreators.js b/state/ActionCreators.js new file mode 100644 index 0000000..b345b7d --- /dev/null +++ b/state/ActionCreators.js @@ -0,0 +1,27 @@ + +export const NEW_DEVICE_DATA = "NEW_DEVICE_DATA"; +export const CHANGE_USER_NAME = "SET_USERNAME"; +export const CHANGE_THEME = "CHANGE_THEME"; +export const START_SESSION = "START_SESSION"; + +export const reportDeviceData = (sessionId, newDataStart, data, analysis) => ({ + type: NEW_DEVICE_DATA, + sessionId: sessionId, + newDataStart: newDataStart, + data: data, + analysis: analysis, +}) + +export const changeUsername = newUsername => ({ + type: CHANGE_USER_NAME, + newUserName: newUsername, +}) + +export const changeTheme = newThemeName => ({ + type: CHANGE_THEME, + newThemeName: newThemeName +}) + +export const startSession = () => ({ + type: START_SESSION +}) \ No newline at end of file diff --git a/state/Reducer.js b/state/Reducer.js new file mode 100644 index 0000000..d836eab --- /dev/null +++ b/state/Reducer.js @@ -0,0 +1,71 @@ +import { combineReducers } from 'redux'; +import { List } from 'immutable'; +import { CHANGE_THEME, CHANGE_USER_NAME, NEW_DEVICE_DATA, START_SESSION } from './ActionCreators'; + +const INITIAL_SETTINGS = { + theme: "hot", + username: "", + deviceURL: "192.168.178.105", + peaksPerLap: 30, + + // advanced + peakDetector: 'SIMPLE', // either 'SIMPLE' or 'ZSCORE' + peakDetectorSimpleThreshold: 500, + + peakDetectorZScoreLag: 8, // peak detector z-score values + peakDetectorZScoreThreshold: 2, + peakDetectorZScoreInfluence: 0.1, +}; + +const INITIAL_CURRENT_SESSION = { + running: false, + rawData: List(), + analysis: { + 'peaks': List(), + 'totalTime': null, + 'activeTime': null, + 'totalMomentum': null, + 'peakFrequency': null, + 'peakMax': null, + // windowed quantities + 'momentumWindow': null, + 'frequencyWindow': null, + 'peakMaxWindow': null, + } +}; + +const settingsReducer = (state = INITIAL_SETTINGS, action) => { + switch (action.type) { + case CHANGE_THEME: + return { ...state, theme: action.newThemeName }; + case CHANGE_USER_NAME: + return { ...state, username: action.newUsername }; + default: + return state + } +}; + +const currentSessionReducer = (state = INITIAL_CURRENT_SESSION, action) => { + switch (action.type) { + case START_SESSION: + return { + running: true, + rawData: List(), + analysis: INITIAL_CURRENT_SESSION.analysis + }; + case NEW_DEVICE_DATA: + return { + running: action.data.length > 0, + rawData: action.data, + analysis: { ...state.analysis, ...analysis }, + } + default: + return state + } +}; + + +export default combineReducers({ + settings: settingsReducer, + session: currentSessionReducer, +}); \ No newline at end of file