New setup from scratch - all modules updated - app now in subfolder

This commit is contained in:
Martin Bauer
2023-09-29 22:04:52 +02:00
parent e28ab91935
commit 9b6bb7f126
63 changed files with 9633 additions and 44259 deletions

3
SwimTracker/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/node_modules
/.expo
.directory

153
SwimTracker/App.js Normal file
View File

@@ -0,0 +1,153 @@
import React from 'react';
import AppLoading from 'expo-app-loading';
import { Ionicons } from '@expo/vector-icons';
import * as Font from 'expo-font';
// Redux + Storage
import swimtrackerReducer from './state/Reducer';
import { createStore } from 'redux';
import { ConnState, WifiState, DeviceReduxCoupling } from './state/DeviceReduxCoupling';
import { Provider } from 'react-redux';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { persistStore, persistReducer } from 'redux-persist'
import hardSet from 'redux-persist/lib/stateReconciler/hardSet'
import { PersistGate } from 'redux-persist/integration/react'
// Navigation
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
// Own views
import MainMenuView from "./views/MainMenuView";
import SettingsView from "./views/SettingsView";
import TrainingView from "./views/TrainingView";
import LastSessionsView from "./views/LastSessionsView";
import ConnectingView from './views/ConnectingView';
import WifiSelectionView from './views/WifiSelectionView';
import WifiPasswordView from './views/WifiPasswordView';
const persistConfig = {
key: 'root',
storage: AsyncStorage,
// stateReconciler: hardSet,
};
const persistedReducer = persistReducer(persistConfig, swimtrackerReducer);
const store = createStore(persistedReducer);
const persistor = persistStore(store);
const Stack = createStackNavigator();
export default class App extends React.Component {
constructor(props) {
super(props);
this.state = {
isReady: false,
disconnected: true,
isProvisioning: false,
};
this.unsubscribe = undefined;
}
componentDidMount() {
this.setState({ isReady: true });
this.device = new DeviceReduxCoupling(store);
let theApp = this;
this.unsubscribe = store.subscribe(() => {
const state = store.getState();
theApp.setState({
disconnected: state.deviceState.connState == ConnState.DISCONNECTED,
isProvisioning: state.deviceState.wifiState == WifiState.AP_PROVISIONING || state.deviceState.wifiState == WifiState.UNKNOWN,
});
});
}
componentWillUnmount() {
if (this.unsubscribe) {
console.log("Unsubscribe");
this.unsubscribe();
}
}
render() {
if (!this.state.isReady) {
return <AppLoading />;
}
const screenOptions = {
headerShown: false,
};
let disconnectedView = (
<>
<Stack.Screen
name="ConnectingView"
options={screenOptions}
component={ConnectingView} />
</>
);
let provisioningView = (
<>
<Stack.Screen
name="WifiSelectionView"
options={screenOptions} >
{props => <WifiSelectionView {...props} device={this.device} />}
</Stack.Screen>
<Stack.Screen
name="WifiPasswordView"
options={screenOptions}
component={WifiPasswordView}
>
</Stack.Screen>
</>
);
let normalView = (
<>
<Stack.Screen
name="Home"
component={MainMenuView}
options={screenOptions}
/>
<Stack.Screen
name="Settings"
options={screenOptions}>
{props => <SettingsView {...props} device={this.device} />}
</Stack.Screen>
<Stack.Screen
name="Training"
component={TrainingView}
options={screenOptions}
/>
<Stack.Screen
name="LastSessions"
component={LastSessionsView}
options={screenOptions}
/>
</>
);
let activeView;
if (this.state.disconnected)
activeView = disconnectedView;
else if (this.state.isProvisioning)
activeView = provisioningView;
else
activeView = normalView;
return (
<Provider store={store}>
<PersistGate loading={<AppLoading />} persistor={persistor}>
<NavigationContainer>
<Stack.Navigator >
{activeView}
</Stack.Navigator>
</NavigationContainer>
</PersistGate>
</Provider>
);
}
}

33
SwimTracker/app.json Normal file
View File

@@ -0,0 +1,33 @@
{
"expo": {
"name": "SwimTracker",
"slug": "SwimTracker",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-localization"
]
}
}

View File

@@ -0,0 +1,17 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What does the "packager-info.json" file contain?
The "packager-info.json" file contains port numbers and process PIDs that are used to serve the application to the mobile device/simulator.
> What does the "settings.json" file contain?
The "settings.json" file contains the server configuration that is used to serve the application manifest.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

BIN
SwimTracker/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
SwimTracker/assets/icon.xcf Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

View File

@@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

109
SwimTracker/cli/index.js Normal file
View File

@@ -0,0 +1,109 @@
import SwimTrackerWebsocketConnection from "../data_processing/SwimTrackerWebsocketConnection.js";
import yargs from 'yargs';
import WS from 'ws';
import { SerialPort } from 'serialport'
import { ReadlineParser } from '@serialport/parser-readline'
import chalk from 'chalk';
/**
* Opens serial port and calls provided function on each line
*
* @param {string} serialPortPath
* @param {(str) => any} onLineReceived
*/
function openSerialConnection(serialPortPath, onLineReceived) {
const port = new SerialPort({
path: serialPortPath,
baudRate: 115200
});
const parser = port.pipe(new ReadlineParser());
parser.on('data', onLineReceived);
}
function basicTest(deviceIpOrHostname, serialPort) {
let lastHostLog = undefined;
let lastDeviceLog = undefined;
function log(color, main, startCol) {
if(lastHostLog === undefined)
lastHostLog = Date.now();
const currentTime = Date.now();
const dt = currentTime - lastHostLog;
lastHostLog = currentTime;
console.log(color((startCol).padEnd(9), "|", String(dt).padStart(5), "|", main));
}
function logDeviceTime(color, main, column1, time) {
if(lastDeviceLog === undefined)
lastDeviceLog = time;
const dt = time - lastDeviceLog;
lastDeviceLog = time;
console.log(color((column1).padEnd(9), "|", String(dt).padStart(5), "|", main));
}
const onStarted = (sessionid) => { log(chalk.yellow, "Session started " + sessionid, "WS Msg") };
const onStopped = () => { log(chalk.yellow, "Session stopped", "WS Msg") };
const onWifiStateInfo = (wifiInfo) => { log(chalk.cyan, JSON.stringify(wifiInfo), "WS Wifi") };
const onWebsocketLog = (logMsg) => {logDeviceTime(chalk.blue, logMsg.msg, "WS Log", String(logMsg.time))};
const onDisconnect = () => { log(chalk.red, "Disconnected", "WS Msg") };
const onConnect = () => {
log(chalk.blue,"Connected to device", "WS Msg")
conn.sendLogStreamStartCommand();
};
let data = [];
const onNewMeasurementData = (newDataAsUint16Arr) => {
const arr = Array.from(newDataAsUint16Arr);
newDataAsUint16Arr.forEach(element => {
data.push(element);
});
const existing = "(" + String(data.length).padStart(5) + ") ";
if(arr.length > 5) {
log(chalk.yellow, existing + "Bulk " + arr.length + " measurements", "WS Data");
} else {
log(chalk.gray, existing + JSON.stringify(Array.from(newDataAsUint16Arr)), "WS Data");
}
};
if(serialPort) {
openSerialConnection(serialPort, (receivedLine) => {
log(chalk.gray, receivedLine, "Serial");
});
}
const conn = new SwimTrackerWebsocketConnection(deviceIpOrHostname, onNewMeasurementData, onStarted, onStopped,
onWifiStateInfo, onConnect, onDisconnect,
{ WebSocket: WS });
conn.onLogMessage = onWebsocketLog;
}
basicTest("192.168.178.56", "/dev/ttyUSB1");
//basicTest("192.168.178.56", undefined);
// {"speeds":[0.4, -0.3],"durations":[1500, 1500]}
/*
Testrun:
Captures:
- streamed data from websocket
- log messages from serial port
- output of serial port
File format:
- timestamp of aquisition
- type
- websocket message
- serial port line
Logging:
- websocket errors always
Commands:
- replay file
- data as csv
*/

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { Animated, TouchableWithoutFeedback, View } from 'react-native';
import PropTypes from 'prop-types';
export default class CycleView extends React.Component {
constructor(props) {
super(props);
this.state = {
activeView: props.initialView
};
this.opacityArr = React.Children.map(props.children,
(c, i) => { console.log("iter", c, i); return new Animated.Value(i === props.initialView ? 1 : 0); });
this.fadeInAnimations = this.opacityArr.map(a => Animated.timing(a, {
toValue: 1,
duration: props.fadeDuration,
useNativeDriver: true
}));
this.fadeOutAnimations = this.opacityArr.map(a => Animated.timing(a, {
toValue: 0,
duration: props.fadeDuration,
useNativeDriver: true
}));
console.log("opacity arr length", this.opacityArr.length, React.Children.count(props.children));
}
_onTouch = () => {
const currentViewIdx = this.state.activeView;
const nextViewIdx = (currentViewIdx + 1) % React.Children.count(this.props.children);
this.fadeOutAnimations[currentViewIdx].start();
this.fadeInAnimations[nextViewIdx].start();
this.setState({ activeView: nextViewIdx });
this.props.onViewChange(nextViewIdx);
}
render() {
const children = React.Children.map(this.props.children, (c, i) => c);
return (
<TouchableWithoutFeedback onPress={this._onTouch} styles={{ flex: 1 }}>
<View styles={{ flex: 1, flexDirection: 'row', width: "100%" }}>
{
React.Children.map(this.props.children, (c, i) => {
return <Animated.View style={{
position: i === 0 ? 'relative' : 'absolute',
left: 0,
right: 0,
opacity: this.opacityArr[i],
transform: [{
scale: this.opacityArr[i].interpolate({
inputRange: [0, 1],
outputRange: [this.props.minScaleOnFade, 1],
})
}]
}}>
{c}
</Animated.View>
})
}
</View>
</TouchableWithoutFeedback>
);
}
}
CycleView.propTypes = {
initialView: PropTypes.number,
fadeDuration: PropTypes.number,
minScaleOnFade: PropTypes.number,
onViewChange: PropTypes.func,
}
CycleView.defaultProps = {
initialView: 0,
fadeDuration: 600,
minScaleOnFade: 0.4,
onViewChange: viewIdx => null,
}

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import Svg, { Polyline, Text, Circle } from 'react-native-svg';
import { connect } from 'react-redux';
function computeTickMarks(largest, mostTicks) {
const minimum = largest / mostTicks
const magnitude = 10 ** Math.floor(Math.log10(minimum))
const residual = minimum / magnitude
let tickInterval = 0;
if (residual > 5)
tickInterval = 10 * magnitude;
else if (residual > 2)
tickInterval = 5 * magnitude;
else if (residual > 1)
tickInterval = 2 * magnitude;
else
tickInterval = magnitude;
let result = [];
let nextTick = tickInterval;
while (nextTick < largest) {
result.push(nextTick);
nextTick += tickInterval;
}
return result;
}
const Graph = props => {
const graphHeight = 100;
const numPoints = 300;
const yLabelSpace = 40;
const graphWidth = numPoints + yLabelSpace;
const minKgScale = 4; // scale such that upper graph value is n kg
const data = props.data.slice(-numPoints);
const maxValueDeviceCoord = data.reduce((running, x) => Math.max(x, running), minKgScale / props.kgFactor);
const maxValueKg = Math.max(maxValueDeviceCoord * props.kgFactor, minKgScale);
const devCoordToSvgX = x => yLabelSpace + x;
const devCoordToSvgY = y => graphHeight - (y * 100 / maxValueDeviceCoord);
const dataInKgToSvgCoordX = devCoordToSvgX;
const dataInKgToSvgCoordY = y => devCoordToSvgY(y / props.kgFactor);
const coordStr = data.map((element, i) => `${devCoordToSvgX(i)}, ${devCoordToSvgY(element)}`).join(" ");
const ticks = computeTickMarks(maxValueKg * 0.9, 4);
let viewBox = `0 0 ${graphWidth} ${graphHeight}`;
const cutOffIndex = Math.max(0, props.data.size - numPoints);
const peaksToDisplay = props.peaks.filter(p => p > cutOffIndex)
const peaksXCoords = peaksToDisplay.map(p => devCoordToSvgX(p - cutOffIndex));
const peaksYCoords = peaksToDisplay.map(p => devCoordToSvgY(props.data.get(p)));
return (
<View style={{ aspectRatio: graphWidth / graphHeight }}>
<Svg height="100%" width="100%" viewBox={viewBox} preserveAspectRatio="none" >
<Polyline
points={coordStr}
stroke="black"
strokeWidth="2"
strokeLinejoin="round"
fill="none"
/>
{ticks.map(tick => (
<React.Fragment key={`fragment${tick}`} >
<Polyline
points={`${yLabelSpace}, ${dataInKgToSvgCoordY(tick)} ${graphWidth}, ${dataInKgToSvgCoordY(tick)}`}
stroke="black"
key={`line${tick}`}
strokeWidth="1"
strokeDasharray="5,5"
strokeLinejoin="round"
fill="none"
/>
<Text x="5" y={`${dataInKgToSvgCoordY(tick)}`}
alignmentBaseline="center"
key={`label${tick}`}
fontSize="9pt">{tick} kg </Text>
</React.Fragment>
)
)}
{peaksXCoords.zip(peaksYCoords).map(peak => (
<Circle
cx={peak[0]}
key={`peak${peak[0]}`}
stroke="black"
fill="black"
cy={peak[1]}
r="3"
/>
))}
</Svg>
</View>
);
};
const mapStateToProps = (state) => {
return {
data: state.deviceState.measurements,
kgFactor: state.settings.analysis.kgFactor,
peaks: state.deviceState.analysis.peaks,
};
};
export default connect(mapStateToProps)(Graph);

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { View, StyleSheet, Text } from 'react-native';
import EntypoIcon from "react-native-vector-icons/Entypo";
import AntDesignIcon from "react-native-vector-icons/AntDesign";
import Fa5Icon from "react-native-vector-icons/FontAwesome5";
const IconCard = props => {
let IconClass;
if (props.iconType === "AntDesign") {
IconClass = AntDesignIcon;
}
else if (props.iconType === "FontAwesome5") {
IconClass = Fa5Icon;
} else if (props.iconType === "Entypo") {
IconClass = EntypoIcon;
}
return (
<View style={styles.card}>
<View style={{ paddingLeft: 20 }}>
<Text style={{ color: 'white', fontSize: props.fontSize, textAlign: "center" }}> {props.value}</Text>
</View>
<View style={{ alignItems: 'center', justifyContent: 'center', paddingLeft: 20 }}>
<IconClass style={{ color: 'white', fontSize: 40 }} name={props.iconName} />
<Text style={{ color: 'white', marginTop: 5 }}> {props.label}</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
margin: 5,
padding: 5,
borderRadius: 6,
justifyContent: 'space-between',
}
});
IconCard.defaultProps = {
fontSize: 65,
flex: 1
};
export default IconCard;

View File

@@ -0,0 +1,56 @@
import React from "react";
import {
View,
ImageBackground,
Text,
TouchableOpacity,
StyleSheet,
} from "react-native";
import EntypoIcon from "react-native-vector-icons/Entypo";
function ImageHeader(props) {
return (
<View style={imageHeaderStyles.container}>
<ImageBackground
source={props.image}
resizeMode="cover"
style={{ flex: 1 }}
>
<View style={imageHeaderStyles.row}>
<TouchableOpacity onPress={() => props.navigation.goBack()}>
<EntypoIcon name="chevron-left" style={imageHeaderStyles.icon}></EntypoIcon>
</TouchableOpacity>
<Text style={imageHeaderStyles.text}>{props.text}</Text>
</View>
</ImageBackground>
</View >
)
}
const imageHeaderStyles = StyleSheet.create({
container: {
flex: 1,
minHeight: 185,
maxHeight: 185,
height: 175,
width: "100%",
},
row: {
paddingTop: 40,
flexDirection: "row",
},
icon: {
color: "white",
fontSize: 40,
paddingRight: 10,
paddingLeft: 10,
},
text: {
color: "white",
fontSize: 30,
},
});
export default ImageHeader;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import {View, StyleSheet, Text} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
const PropValueCard = props => {
return (
<LinearGradient
colors={['#0075c4', '#1A8FDE']}
start={[0, 0]}
end={[1, 0]}
style={styles.gradient}>
<Text style={{color:'white', fontSize: 16}}>
{props.label}
</Text>
<Text style={{color:'white', fontSize: 55}}>
{props.value}
</Text>
</LinearGradient>
);
};
const styles = StyleSheet.create({
gradient : {
flex: 1,
padding: 15,
alignItems: 'center',
borderRadius: 16,
margin:15,
marginRight: 4,
height:120,
justifyContent:'center',
}
});
export default PropValueCard;

View File

@@ -0,0 +1,109 @@
import React from "react";
import {
StyleSheet,
View,
StatusBar,
ImageBackground,
Text,
TouchableOpacity,
} from "react-native";
import EntypoIcon from "react-native-vector-icons/Entypo";
function AdditionalOptionsBottomBar(props) {
return (
<View style={bottomBarStyles.container}>
{ props.leftText ?
<TouchableOpacity onPress={props.onLeftPress} style={bottomBarStyles.button}>
<Text style={bottomBarStyles.text}>{props.leftText} </Text>
</TouchableOpacity> : <View></View>
}
{props.rightText &&
<TouchableOpacity onPress={props.onRightPress} style={bottomBarStyles.button}>
<Text style={bottomBarStyles.text}>{props.rightText}</Text>
</TouchableOpacity>
}
</View>
);
};
const bottomBarStyles = StyleSheet.create({
container: {
flexDirection: "row",
justifyContent: "space-between",
paddingBottom: 30,
},
text: {
color: "rgba(255,255,255,0.5)",
},
button: {
borderStyle: "dotted"
}
})
// ---------------------------------------------------------------------------------------------
function SetupView(props) {
return (
<View style={{ flex: 1 }}>
<StatusBar barStyle="light-content" backgroundColor="rgba(0,0,0,0.4)" translucent={true} />
<ImageBackground
source={require("../assets/pool_sky_background_blurred.jpg")}
resizeMode="cover"
style={{ flex: 1 }}
>
<View style={setupViewStyles.container}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
{props.backButton &&
<TouchableOpacity onPress={() => props.navigation.goBack()}>
<EntypoIcon name="chevron-left" style={setupViewStyles.backButton}></EntypoIcon>
</TouchableOpacity>
}
<Text style={setupViewStyles.headerText}>
{props.headerText}
</Text>
</View>
<View style={{flex: 1, justifyContent: "center"}}>
{props.children}
</View>
<AdditionalOptionsBottomBar leftText={props.lowerLeftButtonText}
onLeftPress={props.onLowerLeftButtonPress}
rightText={props.lowerRightButtonText}
onRightPress={props.onLowerRightButtonPress}>
</AdditionalOptionsBottomBar>
</View>
</ImageBackground>
</View>
);
}
const setupViewStyles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "space-between",
width: "80%",
marginLeft: 40,
marginTop: 60,
},
headerText: {
color: "rgba(255,255,255,1)",
fontSize: 25,
},
subtext: {
color: "rgba(255,255,255,1)",
textAlign: "left",
fontSize: 18,
lineHeight: 25,
width: "80%",
marginBottom: 50,
},
backButton: {
color: "rgba(255,255,255,1)",
fontSize: 40
},
});
export default SetupView;

View File

@@ -0,0 +1,26 @@
// https://flatuicolors.com/palette/defo
const themeColors = {
'TURQUOISE': "rgb(26, 188, 156)",
"EMERALD": "rgb(46, 204, 113)",
"PETER RIVER" : "rgb(52, 152, 219)",
"AMETHYST" : "rgb(155, 89, 182)",
"WET ASPHALT" : "rgb(52, 73, 94)",
"GREEN SEA" : "rgb(22, 160, 133)",
"NEPHRITIS" : "rgb(39, 174, 96)",
"BELIZE HOLE" : "rgb(41, 128, 185)",
"WISTERIA" : "rgb(142, 68, 173)",
"MIDNIGHT BLUE" : "rgb(44, 62, 80)",
"SUN FLOWER" : "rgb(241, 196, 15)",
"CARROT" : "rgb(230, 126, 34)",
"ALIZARIN" : "rgb(231, 76, 60)",
"CLOUDS" : "rgb(236, 240, 241)",
"CONCRETE" : "rgb(149, 165, 166)",
"ORANGE" : "rgb(243, 156, 18)",
"PUMPKIN" : "rgb(211, 84, 0)",
"POMEGRANATE" : "rgb(192, 57, 43)",
"SILVER" : "rgb(189, 195, 199)",
"ASBESTOS" : "rgb(127, 140, 141)",
};
export default themeColors;

View File

@@ -0,0 +1,80 @@
import { PeakDetectorSimple } from './PeakDetection';
import { MovingAverage} from './MovingAverage';
import { List } from 'immutable';
export default class DataAnalysis {
constructor() {
this._resetCache(null, 0);
}
analyze(analysisParameters, sessionId, allMeasurements) {
const cacheValid = (
this.sessionId === sessionId &&
this.analyzedUpToIdx <= allMeasurements.size &&
this.analysisParameters === analysisParameters);
let newData = null;
if (cacheValid) {
newData = allMeasurements.slice(this.analyzedUpToIdx);
}
else {
this._resetCache(analysisParameters, sessionId);
newData = allMeasurements;
console.log("cache reset");
}
const allMeasurementsSize = allMeasurements.size ? allMeasurements.size : allMeasurements.length;
const newDataArr = (typeof newData.toArray ==="function") ? newData.toArray() : newData;
// active time
const newAverages = this.movingAverage.addVector(newDataArr);
this.activeMeasurements += newAverages.reduce((n, val) => {
return n + ((val >= analysisParameters.activeTimeThreshold) ? 1 : 0);
}, 0);
// peaks
const newPeaks = this.peakDetectorSimple.addVector(newDataArr);
this.allPeaks = this.allPeaks.concat(List(newPeaks));
// aggregated sum/max
this.aggregatedMomentum = newData.reduce((sum, x) => sum + x, this.aggregatedMomentum);
this.peakMax = newData.reduce((running, x) => Math.max(x, running), this.peakMax);
// windowed
const windowNumDataPoints = analysisParameters.windowSizeInSecs * analysisParameters.numMeasurementsPerSec;
const windowed = allMeasurements.slice(-windowNumDataPoints);
const peakMaxWindow = windowed.reduce((running, x) => Math.max(x, running), 0);
const momentumWindow = windowed.reduce((sum, x) => sum + x, 0);
this.analyzedUpToIdx = allMeasurementsSize;
return {
peaks: this.allPeaks,
totalTime: allMeasurementsSize / analysisParameters.numMeasurementsPerSec,
activeTime: this.activeMeasurements / analysisParameters.numMeasurementsPerSec,
totalMomentum: this.aggregatedMomentum,
peakMax: this.peakMax,
momentumWindow: momentumWindow,
peakMaxWindow: peakMaxWindow,
};
}
_resetCache(analysisParameters, sessionId) {
this.movingAverage = analysisParameters ? new MovingAverage(analysisParameters.movingAverageWindowSize) : null;
this.activeMeasurements = 0;
this.peakDetectorSimple = analysisParameters ? new PeakDetectorSimple(analysisParameters.peakDetectorSimpleThreshold) : null;
this.allPeaks = List();
this.aggregatedMomentum = 0;
this.peakMax = 0;
this.sessionId = sessionId;
this.analyzedUpToIdx = 0;
this.analysisParameters = analysisParameters;
}
};

View File

@@ -0,0 +1,128 @@
import DeviceHttpDataSource from './DeviceDataSource';
import { List } from 'immutable';
import { reportDeviceData, resetDeviceData } from '../state/ActionCreators';
import { PeakDetectorSimple } from './PeakDetection';
// todo: put in settings?
const NUM_MEASUREMENTS_PER_SECOND = 10;
const WINDOW_SIZE_SECS = 5;
class DataProcessing {
constructor(reduxStore) {
this.store = reduxStore;
this.store.subscribe(this.onStateChange);
this.state = this.store.getState();
this.dataSource = null;
this.peakDetectorSimple = new PeakDetectorSimple(this.state.settings.peakDetectorSimpleThreshold);
this.onDataSourceChanged(this.state.settings.deviceURL);
this.onRunningChanged(this.state.session.running, this.state.settings.deviceURL);
}
onStateChange = () => {
const newState = this.store.getState();
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, newState.settings.deviceURL);
};
if (newState.settings.peakDetectorSimpleThreshold !== this.state.settings.peakDetectorSimpleThreshold) {
this.onAnalysisParameterChange();
};
this.state = newState;
}
resetAnalysis = () => {
this.peakDetectorSimple = new PeakDetectorSimple(this.state.settings.peakDetectorSimpleThreshold);
}
onAnalysisParameterChange = () => {
this.resetAnalysis();
this.peakDetectorSimple.addVector(this.state.session.rawData.toArray());
const analysis = this.analyzeNewMeasurements(data.values, List());
this.store.dispatch(reportDeviceData(this.state.session.sessionId, this.state.session.rawData.size, this.state.session.rawData, analysis));
}
onDataSourceChanged = (newDeviceURL) => {
if (this.dataSource !== null) {
this.dataSource.stop();
this.dataSource = null;
}
this.dataSource = new DeviceHttpDataSource(newDeviceURL + "/api/session/data", this.onNewData);
}
onRunningChanged = (running, deviceURL) => {
let req = new XMLHttpRequest();
if (running) {
//console.log("Starting session", deviceURL + "/api/session/start");
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;
}
req.addEventListener("error", evt => console.log(evt));
req.addEventListener("abort", evt => console.log(evt));
req.send();
}
onNewData = (data) => {
if (data.sessionStartTime == this.state.session.sessionId &&
data.startIndex == this.state.session.rawData.size) {
// normal case, add received data to measurement array
const newData = this.state.session.rawData.concat(List(data.values));
const analysis = this.analyzeNewMeasurements(data.values, this.state.session.rawData);
this.store.dispatch(reportDeviceData(data.sessionStartTime, data.startIndex, newData, analysis));
}
else if (data.startIndex === 0) {
this.resetAnalysis();
const newData = List(data.values);
const analysis = this.analyzeNewMeasurements(data.values, this.state.session.rawData);
this.store.dispatch(reportDeviceData(data.sessionStartTime, data.startIndex, newData, analysis));
} else {
// missed some data -> re-query
console.log("Requery :(");
//console.log("Session times", data.sessionStartTime == this.state.session.sessionId, data.sessionStartTime, this.state.session.sessionId);
//console.log("Index ",data.startIndex == this.state.session.rawData.size, data.startIndex, this.state.session.rawData.size);
this.resetAnalysis();
this.dataSource.startIndex = 0;
this.store.dispatch(resetDeviceData());
}
}
analyzeNewMeasurements = (newData, oldData) => {
const newPeaks = this.peakDetectorSimple.addVector(newData);
const allPeaks = this.state.session.analysis.peaks.concat(List(newPeaks));
const allMeasurements = oldData.concat(List(newData));
const totalMomentum = allMeasurements.reduce((sum, x) => sum + x, 0);
const peakMax = allMeasurements.reduce((running, x) => Math.max(x, running), 0);
// windowed quantities
const windowSizeMeasurements = WINDOW_SIZE_SECS * NUM_MEASUREMENTS_PER_SECOND;
const windowedSeq = allMeasurements.slice(-windowSizeMeasurements);
const peakMaxWindow = windowedSeq.reduce((running, x) => Math.max(x, running), 0);
const momentumWindow = windowedSeq.reduce((sum, x) => sum + x, 0);
return {
peaks: allPeaks,
totalTime: allMeasurements.length / NUM_MEASUREMENTS_PER_SECOND,
activeTime: 0,
totalMomentum: totalMomentum,
peakFrequency: 0,
peakMax: peakMax,
// windowed quantities
momentumWindow: momentumWindow,
frequencyWindow: 0,
peakMaxWindow: peakMaxWindow,
}
};
}
export default DataProcessing;

View File

@@ -0,0 +1,76 @@
import * as msgpack from 'msgpack-lite';
class DeviceHttpDataSource {
constructor(dataUrl, onNewData, pollInterval=800, startIndex = 0) {
this.dataUrl = dataUrl;
this.onNewData = onNewData;
this.pollInterval = pollInterval;
this.startIndex = startIndex;
this.timer = null;
// 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;
this.onNewData(decoded);
} catch (err) {
console.log(err);
}
}
start() {
if (this.timer === null) {
console.log("Start monitoring");
this.timer = setInterval(this.fetchDataHttp, this.pollInterval);
return true;
} else {
return false;
}
}
stop() {
if (this.timer !== null) {
console.log("stop monitoring");
clearInterval(this.timer);
this.timer = null;
return true;
} else {
return false;
}
}
};
export default DeviceHttpDataSource;

View File

@@ -0,0 +1,29 @@
/**
* A moving average computation
*/
export class MovingAverage {
constructor(windowSize) {
this._windowSize = windowSize;
this._queue = [];
this._queueSum = 0;
}
windowSize() {
return this._windowSize;
}
addVector(vec) {
return vec.map(this.add.bind(this));
}
add(value) {
this._queueSum += value;
this._queue.push(value);
if(this._queue.length > this._windowSize) {
this._queueSum -= this._queue[0];
this._queue.shift();
}
return this._queueSum / this._queue.length;
}
};

View File

@@ -0,0 +1,185 @@
/**
* A simple peak detector
*
* Usage: Successively add values via add() and query the indices of the peaks with peaks
*
* A peak is detected if the current point is local maximum (no filtering!) and the current
* value is larger than (threshold + minimum_since_last_peak)
*/
class PeakDetectorSimple {
constructor(threshold) {
this._threshold = threshold;
this._queue = [];
this._last_min = 0;
this._counter = 0;
}
getThreshold() {
return this._threshold;
}
addVector(vec) {
let result = [];
for (let i = 0; i < vec.length; ++i) {
const res = this.add(vec[i]);
if(res !== null)
result.push(res);
}
return result;
}
add(value) {
let result = null;
this._queue.push(value);
if (this._queue.length > 3) {
this._queue.shift();
}
if (this._queue.length !== 3) {
return null;
}
const [last, current, next] = this._queue;
const is_maximum = current > next && current > last;
if (is_maximum && (current - this._last_min) > this._threshold) {
result = this._counter + 1;
this._last_min = current;
}
this._last_min = Math.min(this._last_min, current);
this._counter += 1;
return result;
}
}
/**
* Implementation of Z-Score peak detection according to
* https://stackoverflow.com/questions/22583391/peak-signal-detection-in-realtime-timeseries-data
*/
class PeakDetectorZScore {
constructor(lag, threshold, influence, handleNewPeaks) {
this.peaks = [];
this._filter = ZScoreFilter(lag, threshold, influence);
this._counter = 0;
this._previous_signal = 0;
this._max = null;
this._max_index = null;
this._handleNewPeaks = handleNewPeaks;
}
addVector(vec) {
const callbackBackup = this._handleNewPeaks;
const numPeaksBefore = this.peaks.length;
this._handleNewPeaks = null;
for (let i = 0; i < vec.length; ++i) {
this.add(vec[i]);
}
this._handleNewPeaks = callbackBackup;
if (numPeaksBefore != this.peaks.length) {
this._handleNewPeaks(this.peaks);
}
}
add(value) {
const signal = this._filter.add(value);
if (signal != null) {
const rising_flank = this._previous_signal !== 1 && signal === 1;
const falling_flank = this._previous_signal === 1 && signal !== 1;
if (rising_flank)
this._max = -1;
if (signal === 1 && this._max != null && value > this._max) {
this._max = value;
this._max_index = this._counter;
}
if (falling_flank) {
this.peaks.push(this._max_index);
if (this._handleNewPeaks) {
this._handleNewPeaks(this.peaks);
}
}
this._previous_signal = signal;
}
this._counter += 1;
}
}
export { PeakDetectorSimple, PeakDetectorZScore };
// --------------------------------------- Helper classes -------------------------------------------------------------
class StatisticsQueue {
constructor(size) {
this._size = size;
this._queue = [];
this._queue_sum = 0; // running sum over all elements currently in _queue
this._queue_sum_sq = 0; // sum of squared elements in _queue
}
add(value) {
this._queue.push(value);
this._queue_sum += value;
this._queue_sum_sq += value * value;
if (this._queue.length > this._size) {
const removed = this._queue[0];
this._queue.shift();
this._queue_sum -= removed;
this._queue_sum_sq -= removed * removed;
}
}
get avg() {
return this._queue_sum / self._queue.length;
}
get variance() {
const exp_sq = this._queue_sum_sq / self._queue.length;
const my_avg = self.avg;
return exp_sq - (my_avg * my_avg);
}
get std_deviation() {
return Math.sqrt(this.variance);
}
get filled() {
return this._queue.length === this._size;
}
}
class ZScoreFilter {
constructor(lag, threshold, influence) {
this._threshold = threshold;
this._influence = influence;
this._last_value = null;
this._stat_queue = StatisticsQueue(lag);
}
add(value) {
let sq = this._stat_queue;
if (!sq.filled) {
sq.add(value);
this._last_value = value;
return null;
} else {
const avg = sq.avg;
if (Math.abs(value - avg) > this._threshold * sq.std_deviation) {
const signal = value > avg ? 1 : -1;
const filtered = this._influence * value + (1 - this._influence) * this._last_value;
sq.add(filtered);
this._last_value = filtered;
return signal;
} else {
sq.add(value);
this._last_value = value;
}
}
}
}

View File

@@ -0,0 +1,11 @@
import { PeakDetectorSimple } from './PeakDetection';
describe("test PeakDetectorSimple", () => {
it("detects simple peak", () => {
let pd = new PeakDetectorSimple(40);
const result = pd.addVector([0, 10, 30, 50, 30, 50, 2, 0, 60, 0 ]);
expect(result).toEqual([3, 8]);
});
});

View File

@@ -0,0 +1,213 @@
import ReconnectingWebSocket from 'reconnecting-websocket';
import * as msgpack from 'msgpack-lite';
const OpCodes = {
// from swim tracker device to frontend
ERROR: 1,
INITIAL_INFO: 2,
SESSION_STARTED: 3,
SESSION_STOPPED: 4,
SESSION_NEW_DATA: 5,
ANSWER_USER_LIST: 6,
ANSWER_SESSION_LIST: 7,
WIFI_STATE_RESPONSE: 8,
WIFI_SCAN_RESPONSE: 9,
APP_LAYER_PING: 10,
LOG_UPDATE: 11,
// from frontend to device
START_SESSION: 128,
STOP_SESSION: 129,
TARE: 130,
QUERY_USER_LIST: 131,
QUERY_SESSION_LIST: 132,
WIFI_STATE_SET: 133,
WIFI_STATE_GET: 134,
WIFI_TRIGGER_SCAN: 135,
LOG_STREAMING_START: 136,
LOG_STREAMING_STOP: 137
};
const HEARTBEAT_TIMEOUT = 3000;
const PROVISIONING_IP = "192.168.42.1";
export default class SwimTrackerWebsocketConnection {
/**
* Creates a new persistent websocket connection to a swimtracker device
*
* @param {string} swimTrackerHost hostname or ip of the swimtracker device
* @param {(data: Uint16Array) => any} onData called whenever new measurement data is available
* @param {(sessionId: number) => any} onStarted called when a new measurement session was started
* @param {() => any} onStopped called when session was stopped
* @param {(wifistate : object) => any} onWifiStateInfo wifi state contains "state" (STATION_MODE|AP_PROVISIONING|AP_SECURE) and "hostname"
* @param {() => any} onConnect called when websocket connection was established
* @param {() => any} onDisconnect called when websocket disconnected
*/
constructor(swimTrackerHost, onData, onStarted, onStopped, onWifiStateInfo, onConnect, onDisconnect, reconnectingWsOptions={}) {
this.swimTrackerHost = swimTrackerHost;
this.onData = onData;
this.onStarted = onStarted;
this.onStopped = onStopped;
this.onWifiStateInfo = onWifiStateInfo;
this.onConnect = onConnect;
this.onDisconnect = onDisconnect;
this.onLogMessage = () => {};
// try configured URL and provisioning URL
const urls = [`ws://${swimTrackerHost}:81`, `ws://${PROVISIONING_IP}:81`];
let urlIndex = 0;
const urlProvider = () => urls[urlIndex++ % urls.length]; // round robin url provider
this.ws = new ReconnectingWebSocket(urlProvider, [], { ...reconnectingWsOptions, maxReconnectionDelay: 3000 });
this.ws.onmessage = this._onMessage;
this.ws.onopen = this.onConnect;
this.ws.onclose = this.onDisconnect;
this.ws.onerror = this._onError;
this.ws.binaryType = 'arraybuffer';
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._wifiScanPromises = [];
this.pingTimeout = null;
}
heartbeat() {
clearTimeout(this.pingTimeout);
let connection = this;
this.pingTimeout = setTimeout(() => {
if(connection.ws !== null)
connection.ws.reconnect();
}, HEARTBEAT_TIMEOUT);
}
close() {
if (this.ws !== null) {
this.ws.onmessage = null;
this.ws.onopen = null;
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
}
sendStartCommand() {
this._sendMsg(OpCodes.START_SESSION);
}
sendStopCommand() {
this._sendMsg(OpCodes.STOP_SESSION);
}
sendTareCommand = () => {
this._sendMsg(OpCodes.TARE);
}
sendLogStreamStartCommand = () => {
this._sendMsg(OpCodes.LOG_STREAMING_START);
}
sendLogStreamStopCommand = () => {
this._sendMsg(OpCodes.LOG_STREAMING_STOP);
}
scanWifiNetworks() {
console.log("Trigger wifi scan");
this._sendMsg(OpCodes.WIFI_TRIGGER_SCAN);
let conn = this;
return new Promise((resolve, reject) => {
conn._wifiScanPromises.push({ resolve: resolve, reject: reject });
});
}
sendTareCommand = () => {
this._sendMsg(OpCodes.WIFI_STATE_SET, {
"reset_to_provisioning": true,
});
}
wifiSetModeAP(password) {
this._sendMsg(OpCodes.WIFI_STATE_SET, {
"ap_password": password,
});
}
wifiSetModeSTA(ssid, password) {
console.log("Setting sta mode", ssid, password);
this._sendMsg(OpCodes.WIFI_STATE_SET, {
"sta_ssid": ssid,
"sta_password": password,
});
}
_sendMsg(code, data) {
let msg = undefined;
if (data) {
const serializedData = msgpack.encode(data);
msg = new Uint8Array([code, ...serializedData]);
} else {
msg = new Uint8Array(1);
msg[0] = code;
}
this.ws.send(msg);
}
_onMessage = (e) => {
const dv = new DataView(e.data);
const opCode = dv.getInt8(0);
const payload = new Uint8Array(e.data).slice(1);
this.heartbeat();
if (opCode === OpCodes.INITIAL_INFO) {
const headerSize = 6;
const running = Boolean(dv.getInt8(1));
const sessionId = dv.getUint32(2);
if (running && e.data.byteLength > headerSize) {
const data = new Uint16Array(e.data.slice(headerSize));
this.onStarted(sessionId);
this.onData(data);
} else
this.onStopped();
} else if (opCode === OpCodes.SESSION_STARTED) {
const sessionId = dv.getUint32(1);
this.onStarted(sessionId);
} else if (opCode === OpCodes.SESSION_STOPPED) {
this.onStopped();
} else if (opCode === OpCodes.SESSION_NEW_DATA) {
const data = new Uint16Array(e.data.slice(1));
this.onData(data);
} else if (opCode === OpCodes.WIFI_SCAN_RESPONSE) {
const scanResult = msgpack.decode(payload, { codec: this.msgpackCodec });
for (let i = 0; i < this._wifiScanPromises.length; ++i) {
this._wifiScanPromises[i].resolve(scanResult);
}
this._wifiScanPromises.length = 0;
} else if (opCode === OpCodes.WIFI_STATE_RESPONSE) {
const wifiInfo = msgpack.decode(payload, { codec: this.msgpackCodec });
this.onWifiStateInfo(wifiInfo);
} else if (opCode === OpCodes.LOG_UPDATE) {
const logMsg = msgpack.decode(payload, { codec: this.msgpackCodec });
this.onLogMessage(logMsg);
} else if (opCode === OpCodes.APP_LAYER_PING) {
//console.log("got heartbeat");
}
}
_onError = (ev) => {
console.log("Websocket error", ev);
}
};

44
SwimTracker/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "swimtracker",
"version": "1.0.0",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/webpack-config": "^19.0.0",
"@react-native-async-storage/async-storage": "1.18.2",
"@react-navigation/native": "^6.1.8",
"@react-navigation/stack": "^6.3.18",
"expo": "~49.0.13",
"expo-app-loading": "^2.1.1",
"expo-localization": "~14.3.0",
"expo-status-bar": "~1.6.0",
"i18n-js": "^4.3.2",
"immutable": "^5.0.0-beta.4",
"moment": "^2.29.4",
"msgpack-lite": "^0.1.26",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.72.5",
"react-native-gesture-handler": "~2.12.0",
"react-native-reanimated": "~3.3.0",
"react-native-safe-area-context": "4.6.3",
"react-native-screens": "~3.22.0",
"react-native-svg": "13.9.0",
"react-native-swipe-list-view": "^3.2.9",
"react-native-web": "~0.19.6",
"react-redux": "^8.1.2",
"react-xml-parser": "^1.1.8",
"reconnecting-websocket": "^4.4.0",
"redux": "^4.2.1",
"redux-persist": "^6.0.0"
},
"devDependencies": {
"@babel/core": "^7.20.0"
},
"private": true
}

View File

@@ -0,0 +1,174 @@
import SwimTrackerWebsocketConnection from "../data_processing/SwimTrackerWebsocketConnection";
import DataAnalysis from "../data_processing/DataAnalysis";
import { List } from "immutable";
export const ConnState = {
DISCONNECTED: 'disconnected',
CONNECTED_STOPPED: 'connected_stopped',
CONNECTED_RUNNING: 'connected_running',
CONNECTED_STARTING: 'connected_starting', // start message sent, but device hasn't ack'ed it yet
CONNECTED_STOPPING: 'connected_stopping' // stop message sent..
}
export const WifiState = {
UNKNOWN: 'unknown',
STA: 'sta', // connected to regular wifi
AP_PROVISIONING: 'ap_provisioning', // acting as access point for provisioning
AP_SECURE: 'ap_secure', // acting as access point, password has been set
}
// -------------------------------------------- Actions ---------------------------------------------
export const DEVICE_DISCONNECT = "DEVICE_DISCONNECT";
export const DEVICE_CONNECT = "DEVICE_CONNECT";
export const SESSION_STARTED = "SESSION_STARTED";
export const SESSION_STOPPED = "SESSION_STOPPED";
export const SESSION_NEW_DATA = "SESSION_NEW_DATA";
export const START_SESSION = "START_SESSION";
export const STOP_SESSION = "STOP_SESSION";
export const WIFI_SET_STATE = "WIFI_SET_STATE";
export const reportNewWifiState = (newStateStr, newHostname) => ({
type: WIFI_SET_STATE,
newStateStr: newStateStr,
newHostname: newHostname,
});
export const reportSessionStarted = (sessionId) => ({
type: SESSION_STARTED,
sessionId: sessionId
});
export const reportSessionStopped = () => ({
type: SESSION_STOPPED
});
export const reportNewSessionData = (allMeasurements, analysis) => ({
type: SESSION_NEW_DATA,
data: allMeasurements,
analysis: analysis
});
export const reportDeviceConnect = () => ({
type: DEVICE_CONNECT
});
export const reportDeviceDisconnect = () => ({
type: DEVICE_DISCONNECT
});
export const startSession = () => ({
type: START_SESSION
});
export const stopSession = () => ({
type: STOP_SESSION
});
// -------------------------------------------- Device coupling -------------------------------------
export class DeviceReduxCoupling {
constructor(reduxStore) {
this.reduxStore = reduxStore;
this.analysis = new DataAnalysis();
this.conn = null;
this.reduxStore.subscribe(this._onStateChange);
this._onStateChange();
}
_onStateChange = () => {
const state = this.reduxStore.getState();
if (this.conn === null || (state.settings.swimTrackerHost != this.conn.swimTrackerHost)) {
if( this.conn !== null) {
this.conn.close();
}
this.conn = new SwimTrackerWebsocketConnection(state.settings.swimTrackerHost,
this._onNewData,
(sessionId) => this.reduxStore.dispatch(reportSessionStarted(sessionId)),
() => this.reduxStore.dispatch(reportSessionStopped()),
(response) => this.reduxStore.dispatch(reportNewWifiState(response["state"], response["hostname"])),
() => this.reduxStore.dispatch(reportDeviceConnect()),
() => this.reduxStore.dispatch(reportDeviceDisconnect())
);
}
if (state.deviceState.connState === ConnState.CONNECTED_STARTING) {
console.log("sending start command to connection");
this.conn.sendStartCommand();
}
else if (state.deviceState.connState === ConnState.CONNECTED_STOPPING)
this.conn.sendStopCommand();
}
_onNewData = (newData) => {
const state = this.reduxStore.getState();
const allMeasurements = state.deviceState.measurements.concat(List(newData));
const analysisResult = this.analysis.analyze(state.settings.analysis, state.deviceState.sessionId, allMeasurements);
this.reduxStore.dispatch(reportNewSessionData(allMeasurements, analysisResult));
}
};
// -------------------------------------------- Reducer -----------------------------------------------
const INITIAL_ANALYSIS = {
'peaks': List(),
'totalTime': null,
'totalMomentum': null,
'peakMax': null,
'momentumWindow': null,
'peakMaxWindow': null,
};
const INITIAL_DEVICE_STATE = {
connState: ConnState.DISCONNECTED,
wifiState: WifiState.UNKNOWN,
sessionId: 0,
measurements: List(),
analysis: INITIAL_ANALYSIS,
deviceReportedHostname: "",
};
export const deviceStateReducer = (state = INITIAL_DEVICE_STATE, action) => {
switch (action.type) {
case SESSION_NEW_DATA:
const res = {
...state,
measurements: action.data,
analysis: { ...state.analysis, ...action.analysis }
};
return res;
case DEVICE_CONNECT:
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.CONNECTED_STOPPED };
case DEVICE_DISCONNECT:
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.DISCONNECTED };
case SESSION_STARTED:
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.CONNECTED_RUNNING, sessionId: action.sessionId };
case SESSION_STOPPED:
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.CONNECTED_STOPPED };
case START_SESSION:
if (state.connState === ConnState.SESSION_STARTED)
return state;
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.CONNECTED_STARTING };
case STOP_SESSION:
if (state.connState === ConnState.SESSION_STOPPED)
return state;
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.CONNECTED_STOPPING };
case WIFI_SET_STATE:
let wifiState = WifiState.UNKNOWN;
if (action.newStateStr === "STATION_MODE") { wifiState = WifiState.STA; }
else if (action.newStateStr === "AP_PROVISIONING") { wifiState = WifiState.AP_PROVISIONING; }
else if (action.newStateStr === "AP_SECURE") { wifiState = WifiState.AP_SECURE; }
return { ...state, wifiState: wifiState, deviceReportedHostname: action.newHostname };
default:
//console.log("Unhandled state in deviceStateReducer", action, action.type, "state", state);
return state;
}
};

View File

@@ -0,0 +1,64 @@
import { combineReducers } from 'redux';
import { deviceStateReducer } from "./DeviceReduxCoupling";
export const CHANGE_USER_NAME = "SET_USERNAME";
export const RESET_DEVICE_DATA = "RESET_DEVICE_DATA";
export const CHANGE_SWIMTRACKER_HOSTNAME = "CHANGE_SWIMTRACKER_HOSTNAME";
export const changeUsername = newUsername => ({
type: CHANGE_USER_NAME,
newUserName: newUsername,
});
export const changeSwimTrackerHostname = newSwimTrackerHost => ({
type: CHANGE_SWIMTRACKER_HOSTNAME,
newSwimTrackerHost: newSwimTrackerHost,
});
export const startSession = () => ({
type: START_SESSION
});
export const stopSession = () => ({
type: STOP_SESSION
});
const INITIAL_SETTINGS = {
username: "",
swimTrackerHost: "swimtracker",
analysis: {
peaksPerLap: 30,
windowSizeInSecs: 5,
numMeasurementsPerSec: 10,
kgFactor: 1.0 / (701.0 * 2.4),
peakDetector: 'SIMPLE', // either 'SIMPLE' or 'ZSCORE'
peakDetectorSimpleThreshold: 2000,
peakDetectorZScoreLag: 8, // peak detector z-score values
peakDetectorZScoreThreshold: 2,
peakDetectorZScoreInfluence: 0.1,
activeTimeThreshold: 700,
movingAverageWindowSize: 10 * 3,
}
};
const settingsReducer = (state = INITIAL_SETTINGS, action) => {
switch (action.type) {
case CHANGE_USER_NAME:
return { ...state, username: action.newUsername };
case CHANGE_SWIMTRACKER_HOSTNAME:
return { ...state, swimTrackerHost: action.newSwimTrackerHost };
default:
return state;
}
};
export default combineReducers({
settings: settingsReducer,
deviceState: deviceStateReducer,
});

View File

@@ -0,0 +1,27 @@
let request = obj => {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open(obj.method || "GET", obj.url);
if (obj.headers) {
Object.keys(obj.headers).forEach(key => {
xhr.setRequestHeader(key, obj.headers[key]);
});
}
if(obj.responseType) {
xhr.responseType = obj.responseType;
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject(xhr.statusText);
}
};
xhr.onerror = () => reject(xhr.statusText);
xhr.send(obj.body);
});
};
export default request;

View File

@@ -0,0 +1,45 @@
import moment from 'moment/min/moment-with-locales';
function timeSince(timeStamp, lang = 'de') {
moment.locale(lang);
const now = Math.floor((new Date()).getTime() / 1000);
const secondsPast = now - timeStamp;
if (secondsPast <= 6 * 3600) {
return moment().seconds(-secondsPast).fromNow();
}
else{
const timeStampDate = new Date(timeStamp * 1000);
const dateNow = new Date();
const timeStampMoment = moment.unix(timeStamp);
let dateStr = "";
if (timeStampDate.getDate() == dateNow.getDate())
dateStr = "Heute, " + timeStampMoment.format("HH:mm");
else if (timeStampDate.getDate() + 1 == dateNow.getDate())
dateStr = "Gestern, " + timeStampMoment.format("HH:mm");
else {
dateStr = timeStampMoment.format("ddd, DD.MM.YY um HH:mm");
}
return dateStr;
}
}
const toTimeStr = seconds => {
let minuteStr = String(Math.floor(seconds / 60));
if (minuteStr.length < 2)
minuteStr = "0" + minuteStr;
let secondStr = String(Math.floor(seconds % 60));
if (secondStr.length < 2)
secondStr = "0" + secondStr;
return minuteStr + ":" + secondStr;
}
export { toTimeStr, timeSince };

View File

@@ -0,0 +1,34 @@
import { I18n } from "i18n-js";
import * as Localization from 'expo-localization';
const translation_store = {
en: {
connecting: "Connecting",
connectSubtext: "Please connect your phone to the WiFi of your SwimTracker",
simpleMode: "Simple Mode",
advancedMode: "Advanced Mode",
help: "Need help?",
settings: "Settings",
lastSessions: "Last Sessions",
mainMenu_social: "Social",
mainMenu_swimNow: "Swim now"
},
de : {
connecting: "Verbindung aufbauen",
connectSubtext: "Gehe entweder in das WLAN deines SwimTrackers oder in dein eigenes WLAN, falls du den SwimTracker schon eingerichtet hast.",
simpleMode: "Weniger Einstellungen",
advancedMode: "Mehr Einstellungen",
settings: "Einstellungen",
help: "Hilfe",
lastSessions: "Letzte Sessions",
mainMenu_social: "Freunde",
mainMenu_swimNow: "Jetzt schwimmen",
}
}
export const i18n = new I18n();
i18n.store(translation_store);
i18n.defaultLocale = "en";
i18n.enableFallback = true;
i18n.locale = Localization.locale;

View File

@@ -0,0 +1,106 @@
import React, { useState } from "react";
import {
StyleSheet,
Text,
View,
TextInput,
ActivityIndicator,
} from "react-native";
import SetupView from '../components/SetupView';
import { connect } from 'react-redux';
import { changeSwimTrackerHostname } from '../state/Reducer';
import { i18n } from '../utility/i18n';
const validHostnameRegex = /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))|(^\s*((?=.{1,255}$)(?=.*[A-Za-z].*)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*)\s*$)/;
function isValidHostname(hostname) {
return validHostnameRegex.test(hostname);
}
function ConnectingView(props) {
const [isHostnameValid, setIsHostnameValid] = useState(isValidHostname(props.swimTrackerHost));
const [hostnameTextInput, setHostnameTextInput] = useState(props.swimTrackerHost);
const [advancedMode, setAdvancedMode] = useState(false);
let onHostnameChange = newHostName => {
setHostnameTextInput(newHostName);
const newHostnameValid = isValidHostname(newHostName);
setIsHostnameValid(newHostnameValid);
if (newHostnameValid) {
props.dispatch(changeSwimTrackerHostname(newHostName));
}
return true;
};
const hiddenStyle = advancedMode ? {} : {"display": "none"};
return (
<SetupView
headerText={i18n.t("connecting") + "..."}
lowerLeftButtonText={ i18n.t(advancedMode ? 'simpleMode' : 'advancedMode') }
onLowerLeftButtonPress={() => { setAdvancedMode(!advancedMode); }}
lowerRightButtonText={i18n.t('help')}
>
<View style={{flexDirection: "row", alignItems: "center"}}>
<ActivityIndicator size="large" color="#ffffff" />
<Text style={styles.subtext}>
{i18n.t('connectSubtext')}
</Text>
</View>
{true &&
<View style={StyleSheet.flatten([styles.row, hiddenStyle])}>
<Text style={styles.label}>Host:</Text>
<TextInput
onChangeText={onHostnameChange}
value={hostnameTextInput}
style={{ ...styles.hostnameInput, color: isHostnameValid ? "rgb(255, 255, 255)" : "rgb(255, 150, 150)" }}
placeholderTextColor="rgba(255,255,255,0.5)"
/>
</View>
}
</SetupView>
)
}
const styles = StyleSheet.create({
subtext: {
color: "rgba(255,255,255,1)",
textAlign: "left",
fontSize: 16,
lineHeight: 25,
width: "100%",
//paddingTop: 50,
paddingLeft: 30,
},
row: {
backgroundColor: "rgba(255,255,255,0.4)",
borderRadius: 5,
width: "100%",
height: 50,
flexDirection: "row",
alignItems: "center",
marginTop: 60,
marginBottom: 5,
},
hostnameInput: {
height: 30,
color: "rgba(255,255,255,1)",
width: "80%",
fontSize: 18,
},
label : {
color: "rgb(80, 80, 80)",
marginLeft: 15,
marginRight: 10,
}
});
const mapStateToProps = (state) => {
return { swimTrackerHost: state.settings.swimTrackerHost };
};
export default connect(mapStateToProps)(ConnectingView);

View File

@@ -0,0 +1,278 @@
import React from "react";
import {
ActivityIndicator,
StyleSheet,
View,
StatusBar,
Text,
TouchableOpacity,
RefreshControl,
} from "react-native";
import themeColors from '../components/themeColors';
import EntypoIcon from "react-native-vector-icons/Entypo";
import AntDesignIcon from "react-native-vector-icons/AntDesign";
import FaIcon from "react-native-vector-icons/FontAwesome5";
import ImageHeader from "../components/ImageHeader";
import { SwipeListView } from 'react-native-swipe-list-view';
import { connect } from 'react-redux';
import request from '../utility/PromiseRequest';
import DataAnalysis from '../data_processing/DataAnalysis';
import * as msgpack from 'msgpack-lite';
import { timeSince } from '../utility/TimeUtils';
import XMLParser from 'react-xml-parser';
import {i18n} from '../utility/i18n';
function SessionCard(props) {
return (
<View style={sessionCardStyles.card}>
<View>
<Text style={sessionCardStyles.firstLineText}>{props.textFirstLine}</Text>
</View>
<View style={sessionCardStyles.secondLine}>
<View style={sessionCardStyles.iconTextPair}>
<FaIcon name="stopwatch" style={sessionCardStyles.icon} />
<Text style={sessionCardStyles.secondLineText}>{props.activeTime}</Text>
</View>
<View style={sessionCardStyles.iconTextPair}>
<EntypoIcon name="ruler" style={sessionCardStyles.icon} />
<Text style={sessionCardStyles.secondLineText}>{props.momentum}</Text>
</View>
<View style={sessionCardStyles.iconTextPair}>
<AntDesignIcon name="retweet" style={sessionCardStyles.icon} />
<Text style={sessionCardStyles.secondLineText}>{props.laps}</Text>
</View>
</View>
</View>
)
}
function SessionCardBehindSwipe(props) {
return (
<View style={sessionCardStyles.rowBack}>
<TouchableOpacity
style={sessionCardStyles.deleteButton}
onPress={props.onDelete}
>
<Text style={{ fontSize: 18, color: "white" }}>Löschen</Text>
</TouchableOpacity>
</View>
);
}
const sessionCardStyles = StyleSheet.create({
card: {
backgroundColor: "#559ac8",
borderRadius: 12,
height: 100,
maxHeight: 100,
flex: 1,
flexDirection: "column",
justifyContent: "space-around",
padding: 10,
margin: 10,
paddingLeft: 20,
},
firstLineText: {
color: "white",
fontSize: 22
},
iconTextPair: {
maxWidth: 100,
flex: 1,
flexDirection: "row",
alignItems: "center",
},
secondLine: {
flex: 1,
justifyContent: "space-between",
alignContent: "center",
flexDirection: "row",
maxHeight: 30,
marginTop: 14,
},
icon: {
fontSize: 30,
color: "white",
paddingRight: 10,
},
secondLineText: {
color: "white",
fontSize: 18,
},
spacerHidden: {
flex: 1,
color: "black",
},
rowBack: {
alignItems: 'center',
backgroundColor: themeColors['ALIZARIN'],
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
height: 100,
padding: 10,
margin: 10,
paddingLeft: 20,
borderRadius: 12,
},
deleteButton: {
alignItems: 'center',
bottom: 0,
justifyContent: 'center',
position: 'absolute',
backgroundColor: themeColors['ALIZARIN'],
top: 0,
width: 150,
right: 0,
borderTopRightRadius: 12,
borderBottomRightRadius: 12,
},
})
// ---------------------------------------------------------------------------------------------
function parsePropfind(text) {
const parser = new XMLParser();
const xmlDoc = parser.parseFromString(text);
//const parser = new DOMParser();
//const xmlDoc = parser.parseFromString(text, "text/xml");
const responses = xmlDoc.getElementsByTagName("D:response");
let result = [];
for (let i = 0; i < responses.length; ++i) {
const e = responses[i];
const name = e.getElementsByTagName("D:href")[0].value;
const size = e.getElementsByTagName("D:getcontentlength")[0].value;
result.push({
name: name,
size: parseInt(size),
startTime: parseInt(name.split(".")[0])
});
}
return result;
}
const msgpackCodec = msgpack.createCodec();
msgpackCodec.addExtUnpacker(205, function (byteArr) {
const buffer = byteArr.buffer.slice(byteArr.byteOffset, byteArr.byteLength + byteArr.byteOffset);
const result = new Int16Array(buffer);
return result;
});
async function getSessionDetails(swimTrackerHost, sessionFileName) {
const url = "http://" + swimTrackerHost + "/webdav/" + sessionFileName;
const arrayBuffer = await request({ url: url, responseType: 'arraybuffer' });
return msgpack.decode(new Uint8Array(arrayBuffer), { codec: msgpackCodec });
}
async function getSessionsFromDevice(swimTrackerHost) {
const data = await request({ url: "http://" + swimTrackerHost + "/webdav/", method: "PROPFIND" });
return parsePropfind(data);
}
async function getFullData(swimTrackerHost, analysisSettings) {
const parsed = await getSessionsFromDevice(swimTrackerHost);
for (let index = 0; index < parsed.length; index++) {
const e = parsed[index];
const sessionDetails = await getSessionDetails(swimTrackerHost, e.name);
e.values = sessionDetails.values;
const da = new DataAnalysis();
e.analysis = da.analyze(analysisSettings, e.startTime, e.values);
}
return parsed;
}
// ---------------------------------------------------------------------------------------------
class LastSessionsView extends React.Component {
constructor() {
super();
this.state = { sessions: null, refreshing: false };
}
componentDidMount() {
getFullData(this.props.swimTrackerHost, this.props.analysisSettings).then(
e => this.setState({ sessions: e })
);
}
render() {
const deleteSession = async sessionFileName => {
this.setState({ sessions: null });
await request({ url: "http://" + this.props.swimTrackerHost + "/webdav/" + sessionFileName, method: "DELETE" });
this.setState({ sessions: await getFullData(this.props.swimTrackerHost, this.props.analysisSettings) });
};
const onRefresh = async () => {
this.setState({ refreshing: true });
const newSessions = await getFullData(this.props.swimTrackerHost, this.props.analysisSettings);
this.setState({ sessions: newSessions, refreshing: false });
};
let innerView;
if (this.state.sessions) {
innerView = <SwipeListView
refreshControl={<RefreshControl refreshing={this.state.refreshing} onRefresh={onRefresh} /> }
style={{ width: "100%" }}
keyExtractor={item => item.startTime.toString()}
disableRightSwipe={true}
data={this.state.sessions.reverse()}
renderItem={(data, rowMap) => (
<SessionCard
textFirstLine={timeSince(data.item.startTime)}
laps={(data.item.analysis.peaks.size / this.props.peaksPerLap).toFixed(1)}
momentum={Math.trunc(data.item.analysis.totalMomentum * this.props.kgFactor / 10 / 60)}
activeTime={data.item.analysis.activeTime} />
)}
renderHiddenItem={(data, rowMap) => <SessionCardBehindSwipe onDelete={() => { deleteSession(data.item.name) }} />}
leftOpenValue={0}
rightOpenValue={-120}
stopRightSwipe={-145}
/>
}
else {
innerView = (
<View style={{
flex: 1,
justifyContent: "center",
flexDirection: "row",
justifyContent: "space-around",
}}>
<ActivityIndicator size="large" color="#aaa"></ActivityIndicator>
</View>
);
}
return (
<View style={{ flex: 1 }}>
<StatusBar barStyle="light-content" backgroundColor="rgba(0,0,0,0.4)" translucent={true} />
<View style={{ flex: 1 }}>
<ImageHeader
text={ i18n.t('lastSessions').toUpperCase() }
navigation={this.props.navigation}
image={require("../assets/swimmer.jpg")}
/>
<View style={{ flex: 1, backgroundColor: themeColors["BELIZE HOLE"] }}>
{innerView}
</View>
</View>
</View>
)
}
}
const mapStateToProps = (state) => {
return {
swimTrackerHost: state.settings.swimTrackerHost,
analysisSettings: state.settings.analysis,
kgFactor: state.settings.analysis.kgFactor,
peaksPerLap: state.settings.analysis.peaksPerLap,
};
};
export default connect(mapStateToProps)(LastSessionsView);

View File

@@ -0,0 +1,240 @@
import React from "react";
import {
StyleSheet,
View,
StatusBar,
ImageBackground,
Text,
TouchableOpacity,
} from "react-native";
import themeColors from '../components/themeColors';
import MaterialIcon from "react-native-vector-icons/MaterialIcons";
import MaterialCommIcon from "react-native-vector-icons/MaterialCommunityIcons";
import EntypoIcon from "react-native-vector-icons/Entypo";
import { i18n } from '../utility/i18n';
import { ConnState, startSession } from '../state/DeviceReduxCoupling';
import { connect } from 'react-redux';
// ---------------------------------------------------------------------------------------------
function LargeHeaderView(props) {
return (
<View style={largeHeaderStyles.container}>
<Text style={largeHeaderStyles.titleText}>swimtracker</Text>
<View style={largeHeaderStyles.separator}></View>
<Text style={largeHeaderStyles.subText}>bauer.tech</Text>
</View>
);
}
const largeHeaderStyles = StyleSheet.create({
container: {
height: 160,
maxHeight: 160,
flex: 1,
alignItems: "center",
justifyContent: "flex-end",
paddingBottom: 10,
},
titleText: {
color: "rgba(255,255,255,1)",
fontSize: 48,
},
subText: {
color: "rgba(255,255,255, 0.8)",
marginTop: 15,
textAlign: "center",
},
separator: {
height: 7,
backgroundColor: "rgba(255,255,255,1)",
opacity: 0.5,
width: 230
}
});
// ---------------------------------------------------------------------------------------------
function ButtonGrid(props) {
return (
<View style={buttonGridStyles.rowContainer}>
<View style={buttonGridStyles.columnContainer}>
<TouchableOpacity
onPress={props.onLastSessionsPress}
style={[{ backgroundColor: themeColors["GREEN SEA"] }, buttonGridStyles.button]}
activeOpacity={0.6}
>
<MaterialCommIcon name="swim" style={buttonGridStyles.icon}></MaterialCommIcon>
<Text style={buttonGridStyles.buttonText}>{ i18n.t('lastSessions').toUpperCase().split(" ").join("\n") }</Text>
</TouchableOpacity>
</View>
<View style={buttonGridStyles.columnContainer}>
<TouchableOpacity
onPress={props.onSocialPress}
style={[{ backgroundColor: "#444" }, buttonGridStyles.button]}
activeOpacity={0.6}
disabled={true}
>
<MaterialCommIcon name="account-group" style={buttonGridStyles.icon}></MaterialCommIcon>
<Text style={buttonGridStyles.buttonText}>{ i18n.t('mainMenu_social').toUpperCase()}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={props.onSettingsPress}
style={[{ backgroundColor: themeColors["MIDNIGHT BLUE"] }, buttonGridStyles.button]}
activeOpacity={0.6}
>
<MaterialIcon name="settings" style={buttonGridStyles.icon}></MaterialIcon>
<Text style={buttonGridStyles.buttonText}>{ i18n.t('settings').toUpperCase()}</Text>
</TouchableOpacity>
</View>
</View>
)
}
const buttonGridStyles = StyleSheet.create({
rowContainer: {
flex: 1,
flexDirection: "column",
justifyContent: "space-around",
alignItems: "center",
paddingTop: 20,
paddingBottom: 50,
},
columnContainer: {
flex: 1,
width: "100%",
flexDirection: "row",
justifyContent: "space-around",
alignItems: "center",
},
button: {
flex: 1,
margin: 20,
padding: 20,
width: 120,
height: 130,
borderRadius: 10,
opacity: 0.95,
justifyContent: "space-around",
alignItems: "center",
},
buttonText: {
color: "rgba(255,255,255,1)",
textAlign: "center",
fontSize: 16,
},
icon: {
color: "rgba(255,255,255,1)",
fontSize: 60,
}
});
// ---------------------------------------------------------------------------------------------
function FullWidthButton(props) {
let textStyle = [fullWidthButtonStyles.buttonText];
let iconStyle = [fullWidthButtonStyles.icon];
if (props.disabled) {
textStyle.push(fullWidthButtonStyles.buttonTextDisabled);
iconStyle.push(fullWidthButtonStyles.iconDisabled);
}
return (
<TouchableOpacity
onPress={props.onPress}
style={fullWidthButtonStyles.container}
disabled={props.disabled}
activeOpacity={0.6}>
<View style={{ flex: 1, flexDirection: "row", justifyContent: "center" }}>
<EntypoIcon name="air" style={iconStyle}></EntypoIcon>
<Text style={textStyle}>{props.text}</Text>
</View>
</TouchableOpacity>
)
}
const fullWidthButtonStyles = StyleSheet.create({
container: {
flex: 1,
maxHeight: 70,
backgroundColor: themeColors["WET ASPHALT"],
flexDirection: "row",
alignItems: "center",
//paddingBottom: 10,
justifyContent: "center",
},
buttonText: {
padding: 10,
color: "rgba(255,255,255,1)",
fontSize: 25,
fontWeight: "600",
textAlign: "center",
},
buttonTextDisabled: {
opacity: 0.3,
},
icon: {
fontSize: 40,
padding: 10,
color: themeColors["GREEN SEA"],
},
iconDisabled: {
color: themeColors["POMEGRANATE"],
},
});
// ---------------------------------------------------------------------------------------------
function MainMenuView(props) {
const s = props.connState;
let startButtonText = i18n.t('mainMenu_swimNow').toUpperCase();
let startButtonDisabled = false;
if (s === ConnState.DISCONNECTED) {
startButtonText = "NICHT VERBUNDEN";
startButtonDisabled = true;
} else if (s === ConnState.CONNECTED_RUNNING || s === ConnState.CONNECTED_STARTING) {
startButtonText = "TRAINING LÄUFT";
} else if (s === ConnState.CONNECTED_STOPPING) {
startButtonDisabled = true;
}
const onStartButtonPress = () => {
if (!props.connState !== ConnState.CONNECTED_RUNNING) {
props.dispatch(startSession());
}
props.navigation.navigate('Training')
};
return (
<View style={{ flex: 1 }}>
<StatusBar barStyle="light-content" backgroundColor="rgba(0,0,0,0.4)" translucent={true} />
<ImageBackground
source={require("../assets/pool_sky_background_blurred.jpg")}
resizeMode="cover"
style={{ flex: 1 }}
>
<View style={{ flex: 1 }}>
<LargeHeaderView />
<ButtonGrid
onSettingsPress={() => props.navigation.navigate('Settings')}
onLastSessionsPress = {() => props.navigation.navigate("LastSessions")}
/>
<FullWidthButton
onPress={onStartButtonPress}
text={startButtonText}
disabled={startButtonDisabled} />
</View>
</ImageBackground>
</View>
);
}
const mapStateToProps = (state) => {
return { connState: state.deviceState.connState };
};
export default connect(mapStateToProps)(MainMenuView);

View File

@@ -0,0 +1,252 @@
import React, { useState, useEffect } from "react";
import {
StyleSheet,
View,
StatusBar,
TextInput,
Text,
Switch,
} from "react-native";
import themeColors from '../components/themeColors';
import ImageHeader from "../components/ImageHeader";
import { connect } from 'react-redux';
import { TouchableOpacity } from "react-native-gesture-handler";
import request from '../utility/PromiseRequest';
import { i18n } from '../utility/i18n';
// ---------------------------------------------------------------------------------------------
function SettingsTextInput(props) {
return (
<React.Fragment>
<Text style={settingsGroupStyles.label}>{props.label}</Text>
<TextInput
style={settingsGroupStyles.textInput}
placeholder={props.placeholder}
placeholderTextColor="rgba(167,167,167,1)"
selectionColor='rgb(120,120,120)'
value={props.value}
></TextInput>
</React.Fragment>
);
}
function SettingsSwitch(props) {
const [isEnabled, setIsEnabled] = useState(false);
const toggleSwitch = () => setIsEnabled(previousState => !previousState);
return (
<React.Fragment>
<Text style={settingsGroupStyles.label}>{props.label}</Text>
<Switch
value={isEnabled}
//thumbColor={themeColors["WET ASPHALT"]}
onValueChange={toggleSwitch}
trackColor={{ false: "#767577", true: themeColors["NEPHRITIS"] }}
//thumbColor={isEnabled ? "#f5dd4b" : "#f4f3f4"}
ios_backgroundColor="grey"
style={settingsGroupStyles.switch}
/>
</React.Fragment>
)
}
function SettingsButton(props) {
return (
<React.Fragment>
<Text style={settingsGroupStyles.label}>{props.label}</Text>
<TouchableOpacity style={settingsGroupStyles.buttonTouchable} onPress={props.onPress}>
<Text style={settingsGroupStyles.buttonText}>{props.buttonText}</Text>
</TouchableOpacity>
</React.Fragment>
)
}
function SettingsText(props) {
return (
<React.Fragment>
<Text style={settingsGroupStyles.label}>{props.label}</Text>
<Text style={settingsGroupStyles.text}>{props.text}</Text>
</React.Fragment>
)
}
function SettingsSlider(props) {
/*
<Slider
value={props.value}
disabled={props.disabled}
thumbTintColor={themeColors["WET ASPHALT"]}
minimumTrackTintColor={themeColors["CLOUDS"]}
maximumTrackTintColor={themeColors["CLOUDS"]}
style={settingsGroupStyles.slider}
/>
*/
return (
<React.Fragment>
<Text style={settingsGroupStyles.label}>{props.label}</Text>
</React.Fragment>
)
}
function SettingsCombo(props) {
}
function SettingsGroup(props) {
return (
<View style={settingsGroupStyles.container}>
<Text style={settingsGroupStyles.title}>{props.title}</Text>
<View style={settingsGroupStyles.subsettings}>
{React.Children.map(props.children, (child, idx) =>
<View style={idx == 0 ? [settingsGroupStyles.row, settingsGroupStyles.firstRow] : settingsGroupStyles.row}>
{child}
</View>
)}
</View>
</View>
);
};
const settingsGroupStyles = StyleSheet.create({
container: {
padding: 20,
paddingRight: 30,
},
title: {
color: "white",
fontSize: 20,
fontWeight: "600",
},
subsettings: {
padding: 10,
paddingLeft: 30,
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderTopColor: "rgba(255, 255,255, 0.6)",
paddingTop: 5,
paddingBottom: 5,
borderTopWidth: 0.5,
minHeight: 40,
},
firstRow: {
paddingTop: 0,
borderTopWidth: 0,
},
label: {
color: "white",
fontSize: 17,
},
textInput: {
color: "rgba(255,255,255,1)",
marginTop: 8,
textAlign: "right",
},
slider: {
minWidth: 100,
width: 150,
//minHeight: 50,
},
switch: {
//minHeight: 50,
//minWidth: 80,
},
buttonText: {
color: "rgba(255,255,255,1)",
width: "100%",
textAlign: "center",
},
text : {
color: "rgba(255,255,255,1)",
width: "100%",
textAlign: "right",
},
buttonTouchable: {
backgroundColor: themeColors["CARROT"],
width: 128,
padding: 10,
justifyContent: "center",
borderRadius: 4,
}
});
// ---------------------------------------------------------------------------------------------
async function queryDeviceFirmwareVersion(swimTrackerHost) {
const result = await request({ url: "http://" + swimTrackerHost + "/api/status", responseType: "json" });
return result["firmware"]["version"];
}
async function queryNewestFirmwareVersion() {
const QUERY_URL = "https://swimtracker-update.bauer.tech/VERSION";
const result = await request({ url: QUERY_URL, responseType: "text" });
console.log("newest firmware version, got", result);
return result;
}
function SettingsView(props) {
const [deviceFirmwareVersion, setDeviceFirmwareVersion] = useState("");
const [newestFirmwareVersion, setNewestFirmwareVersion] = useState("");
useEffect(() => {
Promise.all([queryDeviceFirmwareVersion(props.settings.swimTrackerHost), queryNewestFirmwareVersion()]).then(
(values) => {
setDeviceFirmwareVersion(values[0]);
setNewestFirmwareVersion(values[1]);
}
);
});
const doFirmwareUpdate = () => {
request({ url: "http://" + props.settings.swimTrackerHost + "/api/firmwareupdate", responseType: "text"});
};
return (
<View style={{ flex: 1 }}>
<StatusBar barStyle="light-content" backgroundColor="rgba(0,0,0,0.4)" translucent={true} />
<View style={{ flex: 1 }}>
<ImageHeader
text={i18n.t('settings').toUpperCase()}
navigation={props.navigation}
image={require("../assets/infinity_pool2.jpg")}
/>
<View style={{ flex: 1, backgroundColor: themeColors["BELIZE HOLE"] }}>
<SettingsGroup title="swimtracker Device">
<SettingsTextInput
label="URL/IP"
placeholder="swimtracker-????"
value={props.settings.swimTrackerHost}
/>
<SettingsSwitch label="Start automatically" />
<SettingsSwitch label="Stop automatically" />
<SettingsButton label="Tare" buttonText="GO" onPress={props.device.conn.sendTareCommand} />
<SettingsButton label="WiFi config" buttonText="Reset" onPress={props.device.conn.wifiResetToProvisioning} />
<SettingsText label="Firmware version" text={deviceFirmwareVersion}></SettingsText>
<SettingsText label="Newest Firmware" text={newestFirmwareVersion}></SettingsText>
<SettingsButton label="Update Firmware" buttonText="GO" onPress={doFirmwareUpdate}></SettingsButton>
</SettingsGroup>
</View>
</View>
</View>
)
}
const mapStateToProps = (state) => {
return { settings: state.settings };
};
export default connect(mapStateToProps)(SettingsView);

View File

@@ -0,0 +1,122 @@
import React from "react";
import {
StyleSheet,
View,
StatusBar,
Text,
TouchableOpacity
} from "react-native";
import themeColors from '../components/themeColors';
import EntypoIcon from "react-native-vector-icons/Entypo";
import { connect } from 'react-redux';
import { useKeepAwake } from 'expo-keep-awake';
import { stopSession } from '../state/DeviceReduxCoupling';
import CycleView from '../components/CycleView';
import IconCard from '../components/IconCard';
import Graph from '../components/Graph';
import {toTimeStr} from '../utility/TimeUtils';
function SmallHeaderView(props) {
return (
<View style={smallHeaderStyles.container}>
<View style={smallHeaderStyles.row}>
<TouchableOpacity onPress={() => props.navigation.goBack()}>
<EntypoIcon name="chevron-left" style={smallHeaderStyles.backIcon}></EntypoIcon>
</TouchableOpacity>
<Text style={smallHeaderStyles.text}>{props.text}</Text>
<TouchableOpacity onPress={props.onStopPressed}>
<EntypoIcon name="controller-stop" style={smallHeaderStyles.stopIcon}></EntypoIcon>
</TouchableOpacity>
</View>
</View >
)
}
const smallHeaderStyles = StyleSheet.create({
container: {
flex: 1,
minHeight: 80,
maxHeight: 80,
height: 80,
width: "100%",
backgroundColor: themeColors["WET ASPHALT"],
},
row: {
paddingTop: 30,
flexDirection: "row",
justifyContent: "space-between",
},
backIcon: {
color: "white",
fontSize: 40,
},
stopIcon: {
color: themeColors["ALIZARIN"],
fontSize: 40,
paddingRight: 10,
paddingLeft: 10,
},
text: {
color: "white",
fontSize: 30,
},
});
// ---------------------------------------------------------------------------------------------
function TrainingView(props) {
useKeepAwake();
const analysis = props.session.analysis;
const laps = (analysis.peaks.size / props.peaksPerLap).toFixed(1);
const totalMomentum = Math.trunc(analysis.totalMomentum * props.kgFactor / 10 / 60);
const onStopPressed = () => {
props.dispatch(stopSession());
props.navigation.navigate('Home');
};
return (
<View style={{ flex: 1 }}>
<StatusBar hidden={true} />
<View style={{ flex: 1 }}>
<SmallHeaderView text="TRAINING" navigation={props.navigation} onStopPressed={onStopPressed} />
<View style={trainingViewStyles.container}>
<CycleView>
<IconCard label="BAHNEN" value={laps} iconName="retweet" iconType="AntDesign" />
<IconCard label="ZÜGE" value={analysis.peaks.size} iconName="dashboard" iconType="AntDesign" />
</CycleView>
<CycleView>
<IconCard label="DAUER" value={toTimeStr(analysis.totalTime)} iconName="clock" iconType="FontAwesome5" />
<IconCard label="AKTIVE DAUER" value={toTimeStr(analysis.activeTime)} iconName="stopwatch" iconType="FontAwesome5" />
</CycleView>
<IconCard label="KRAFT" value={totalMomentum} iconName="ruler" iconType="Entypo" />
<Graph></Graph>
</View>
</View>
</View>
)
}
const trainingViewStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: themeColors["BELIZE HOLE"],
padding: 20,
justifyContent: "space-around",
}
});
const mapStateToProps = (state) => {
return {
session: state.deviceState,
peaksPerLap: state.settings.analysis.peaksPerLap,
theme: state.settings.theme,
kgFactor: state.settings.analysis.kgFactor,
};
};
export default connect(mapStateToProps)(TrainingView);

View File

@@ -0,0 +1,182 @@
import React, { useState, useEffect } from "react";
import {
StyleSheet,
Text,
View,
TouchableOpacity,
TextInput,
Keyboard
} from "react-native";
import SetupView from '../components/SetupView';
import EvilIcon from "react-native-vector-icons/EvilIcons";
import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons";
import themeColors from '../components/themeColors';
function WifiPasswordView(props) {
props = { ...props, ...props.route.params };
useEffect(() => {
Keyboard.addListener("keyboardDidShow", _keyboardDidShow);
Keyboard.addListener("keyboardDidHide", _keyboardDidHide);
// cleanup function
return () => {
Keyboard.removeListener("keyboardDidShow", _keyboardDidShow);
Keyboard.removeListener("keyboardDidHide", _keyboardDidHide);
};
}, []);
const [keyboardStatus, setKeyboardStatus] = useState(undefined);
const [password1, setPassword1] = useState("");
const [password2, setPassword2] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const _keyboardDidShow = () => setKeyboardStatus(true);
const _keyboardDidHide = () => setKeyboardStatus(false);
let iconName = "wifi-strength-" + props.strength;
if (props.lock) {
iconName += "-lock";
}
const onSubmit = () => {
if (props.confirmPwInput && password1 != password2)
setErrorMsg("Passwords don't match");
else if (password1.length < 8)
setErrorMsg("Password has to be at least 8 characters long")
else if (password1.length > 128)
setErrorMsg("Password too long");
else {
props.onSubmit(props.ssid, password1);
setErrorMsg("");
}
};
return (
<SetupView
headerText="WiFi Password"
lowerRightButtonText="Need help?"
backButton={true}
navigation={props.navigation}
>
{!keyboardStatus &&
<Text style={styles.subtext}>
{props.subText}
</Text>
}
<View style={styles.formContainer}>
<View style={[styles.row, { backgroundColor: "rgba(155,155,155,0.8)" }]}>
<MaterialIcon style={styles.ssidIcon} name={iconName}></MaterialIcon>
<Text style={styles.ssidLabel} >{props.ssid}</Text>
</View>
<View style={styles.row}>
<EvilIcon name="lock" style={styles.ssidIcon}></EvilIcon>
<TextInput style={styles.passwordInput}
onChangeText={setPassword1}
autoCompleteType="password"
placeholder="Password"
placeholderTextColor="rgba(255,255,255,0.5)"
secureTextEntry={true}
></TextInput>
</View>
{props.confirmPwInput &&
< View style={styles.row}>
<EvilIcon name="lock" style={styles.ssidIcon}></EvilIcon>
<TextInput style={styles.passwordInput}
onChangeText={setPassword2}
autoCompleteType="password"
placeholder="Repeat Password"
placeholderTextColor="rgba(255,255,255,0.5)"
secureTextEntry={true}
></TextInput>
</View>
}
{errorMsg.length > 0 &&
<View>
<Text style={{ color: "red", paddingTop: 10, paddingLeft: 55 }}>{errorMsg}</Text>
</View>
}
<TouchableOpacity style={[styles.row, styles.button]} onPress={onSubmit}>
<Text style={[styles.ssidLabel, { alignSelf: "center", textAlign: "center" }]}>{props.buttonText}</Text>
</TouchableOpacity>
</View>
</SetupView >
);
}
WifiPasswordView.defaultProps = {
lock: true,
strength: 2,
ssid: "TheWLANName",
confirmPwInput: false,
buttonText: "Set Password",
subText: "Please enter password for your home WiFi"
}
WifiPasswordView.defaultProps = {
lock: true,
strength: 3,
ssid: "swimtracker-E2842S",
subText: "Use this option only if you're home WiFi doesn't reach the SwimTracker. The SwimTracker creates its own WiFi with the password you set here.",
confirmPwInput: true,
buttonText: "Set Password",
}
const styles = StyleSheet.create({
subtext: {
color: "rgba(255,255,255,1)",
textAlign: "left",
fontSize: 16,
lineHeight: 25,
width: "80%",
paddingBottom: 30,
},
formContainer: {
},
row: {
backgroundColor: "rgba(255,255,255,0.4)",
borderRadius: 5,
width: "100%",
height: 50,
flexDirection: "row",
alignItems: "center",
marginTop: 5,
marginBottom: 5,
},
ssidLabel: {
color: "rgba(255,255,255,1)",
fontSize: 18,
width: "100%"
},
button: {
marginTop: 20,
backgroundColor: themeColors["GREEN SEA"],
justifyContent: "center"
},
ssidIcon: {
fontSize: 25,
color: "rgba(255,255,255,1)",
marginLeft: 15,
marginRight: 15,
},
passwordInput: {
height: 30,
color: "rgba(255,255,255,1)",
width: "100%",
fontSize: 18,
}
});
export default WifiPasswordView;

View File

@@ -0,0 +1,194 @@
import React from 'react';
import {
StyleSheet,
Text,
View,
TouchableOpacity,
ScrollView,
ActivityIndicator,
} from "react-native";
import SetupView from '../components/SetupView';
import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons";
import { connect } from 'react-redux';
import { changeSwimTrackerHostname } from '../state/Reducer';
function WifiListElement(props) {
let iconName = "wifi-strength-" + props.strength;
if (props.lock) {
iconName += "-lock";
}
return (
<TouchableOpacity onPress={props.onPress}>
<View style={wifiListElementStyles.container}>
<MaterialIcon style={wifiListElementStyles.icon} name={iconName}></MaterialIcon>
<Text style={wifiListElementStyles.text} >{props.text}</Text>
</View>
</TouchableOpacity>
)
}
const wifiListElementStyles = {
container: {
backgroundColor: "rgba(255,255,255,0.4)",
borderRadius: 5,
width: "100%",
height: 50,
flexDirection: "row",
alignItems: "center",
marginTop: 8,
marginBottom: 8,
},
icon: {
fontSize: 25,
color: "rgba(255,255,255,1)",
marginLeft: 15,
marginRight: 15,
},
text: {
color: "rgba(255,255,255,1)",
fontSize: 18,
width: "100%"
}
};
// ---------------------------------------------------------------------------------------------
class WifiSelectionView extends React.Component {
constructor() {
super();
this.state = { wifiInfo: [] };
this.mounted = false;
}
processDeviceResponse(response) {
// sort from strong to weak
response.sort((e1, e2) => {
if (e1.rssi > e2.rssi)
return -1;
if (e1.rssi < e2.rssi)
return 1;
else
return 0;
});
let ssidsAlreadyAdded = {};
let result = [];
for (let i = 0; i < response.length; i++) {
if (response[i].ssid in ssidsAlreadyAdded)
continue;
const locked = (response[i].sec != "open");
let strength = 1;
if (response[i].rssi > -30)
strength = 4;
else if (response[i].rssi > -67)
strength = 3;
else if (response[i].rssi > -70)
strength = 2;
result.push({ ssid: response[i].ssid, locked: locked, strength: strength });
ssidsAlreadyAdded[response[i].ssid] = true;
}
return result;
}
componentDidMount() {
let component = this;
component.mounted = true;
this.props.device.conn.scanWifiNetworks().then(
(result) => {
if(component.mounted) {
this.setState({ wifiInfo: this.processDeviceResponse(result) })
}
}
);
}
componentWillUnmount() {
this.mounted = false;
}
render() {
let inner;
if (this.state.wifiInfo.length > 0) {
inner = (
<View style={styles.listContainer}>
<ScrollView style={{centerContent: true, paddingTop: 20}}>
{this.state.wifiInfo.map(e => (
<WifiListElement
text={e.ssid}
strength={e.strength}
lock={e.locked}
key={e.ssid}
onPress={() => {
this.props.navigation.navigate("WifiPasswordView", {
ssid: e.ssid,
lock: e.locked,
strength: e.strength,
confirmPwInput: false,
buttonText: "OK",
subText: "Please enter the password for your home WiFi",
onSubmit: (ssid, pw) => {
console.log("1");
this.props.device.conn.wifiSetModeSTA(ssid, pw);
console.log("2", this.props.deviceReportedHostname, changeSwimTrackerHostname, this.props);
this.props.dispatch(changeSwimTrackerHostname(this.props.deviceReportedHostname));
console.log("3");
},
});
}}>
</WifiListElement>)
)}
</ScrollView>
</View>
)
}
else {
inner = (
<View style={{ alignItems: "center", justifyContent:"center", height: "100%" }}>
<View style={{ paddingBottom: 20 }}><Text style={{ fontSize: 16, color: "#fff"}}>Scanning WiFi networks</Text></View>
<ActivityIndicator size="large" color="#ffffff" />
</View>
)
}
return (
<SetupView
headerText="WiFi Connection"
lowerLeftButtonText="My WiFi wasn't found"
onLowerLeftButtonPress={() => {
this.props.navigation.navigate("WifiPasswordView", {
ssid: "swimtracker-E2842S", // todo real id here
lock: true,
strength: 4,
confirmPwInput: true,
buttonText: "Set Password",
subText: "Use this option only if you're home WiFi doesn't reach the SwimTracker. The SwimTracker creates its own WiFi with the password you set here.",
onSubmit: (ssid, pw) => {
this.props.device.conn.wifiSetModeAP(pw);
},
});
}}
lowerRightButtonText="Need help?"
>
{inner}
</SetupView>
)
}
}
const styles = StyleSheet.create({
listContainer: {
height: "75%",
flex: 1,
}
});
const mapStateToProps = (state) => {
return { deviceReportedHostname: state.deviceState.deviceReportedHostname };
};
export default connect(mapStateToProps)(WifiSelectionView);

9053
SwimTracker/yarn.lock Normal file

File diff suppressed because it is too large Load Diff