diff --git a/src/mobile-app/components/sensor.module.scss b/src/mobile-app/components/sensor.module.scss index 57d59be..30f85dc 100644 --- a/src/mobile-app/components/sensor.module.scss +++ b/src/mobile-app/components/sensor.module.scss @@ -123,6 +123,33 @@ border-right: 2px solid #bfe9c2; margin: 0 5px; } + + .tsvRight { + .tsvInfo { + display: flex; + flex-direction: column; + gap: 6px; + + .tsvInfoRow { + display: flex; + align-items: center; + text-align: right; + justify-content: flex-end; + gap: 10px; + + div:last-child { + min-width: 100px; + text-align: left; + } + } + + select { + padding: 8px; + border-radius: 5px; + background-color: #fff; + } + } + } } .sensorSelector { diff --git a/src/mobile-app/components/sensor.tsx b/src/mobile-app/components/sensor.tsx index 063b36d..fd38522 100644 --- a/src/mobile-app/components/sensor.tsx +++ b/src/mobile-app/components/sensor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from "react"; +import React, { useState, useRef, useMemo, useEffect } from "react"; import { SensorValue } from "./sensor-value"; import { Sensor, IConnectDevice, SelectDeviceFn, ITimeSeriesCapabilities, MaxNumberOfTimeSeriesValues, ISensorValues } from "../../sensors/sensor"; import { useSensor } from "../hooks/use-sensor"; @@ -17,7 +17,7 @@ interface ISensorSelectorProps { cancel: () => void; } -const maxTimeSeriesValues = 5; +const maxTime = 3; export const SensorSelectorComponent: React.FC = ({devices, selectDevice, cancel}) => { const sortedDevices = devices.sort((a, b) => a.id.localeCompare(b.id)); @@ -51,6 +51,7 @@ interface ISensorComponentProps { setManualEntryMode?: (flag: boolean) => void; isTimeSeries: boolean; timeSeriesCapabilities?: ITimeSeriesCapabilities; + setTimeSeriesMeasurementPeriod?: (measurementPeriod: number) => void; } const iconClass = { @@ -65,14 +66,51 @@ const iconClassHi = { error: css.errorIcon }; -export const SensorComponent: React.FC = ({sensor, manualEntryMode, setManualEntryMode, isTimeSeries, timeSeriesCapabilities}) => { +export const SensorComponent: React.FC = ({sensor, manualEntryMode, setManualEntryMode, isTimeSeries, timeSeriesCapabilities, setTimeSeriesMeasurementPeriod}) => { const {connected, connecting, deviceName, values, error} = useSensor(sensor); const [devicesFound, setDevicesFound] = useState([]); const [showDeviceSelect, setShowDeviceSelect] = useState(false); const selectDevice = useRef(); const cancelSelectDevice = useRef(); + + // generate a sliding window of time series values const latestValuesRef = useRef([]); + useEffect(() => { + if (!sensor.connected || !isTimeSeries || !timeSeriesCapabilities) { + latestValuesRef.current = []; + return; + } + + const valueKey = timeSeriesCapabilities.valueKey as keyof ISensorValues; + const timeDelta = sensor.pollInterval / 1000; + const hasValues = latestValuesRef.current.length > 0; + let latestTime = hasValues ? latestValuesRef.current[latestValuesRef.current.length - 1].time : 0; + let nextTime = hasValues ? latestTime + timeDelta : 0; + if (latestTime >= maxTime) { + nextTime = maxTime; + latestTime = maxTime; + latestValuesRef.current = latestValuesRef.current.slice(1).map(({value}, index) => { + return {value, time: index * timeDelta}; + }); + } + latestValuesRef.current.push({value: values[valueKey] ?? 0, time: nextTime}); + latestValuesRef.current[0].capabilities = timeSeriesCapabilities; + }, [isTimeSeries, sensor, timeSeriesCapabilities, values]); + + const timeSeriesPeriods = useMemo(() => { + if (!timeSeriesCapabilities) { + return []; + } + + const {measurementPeriod, minMeasurementPeriod, defaultMeasurementPeriod} = timeSeriesCapabilities; + + const result: number[] = [10, 20, 50, 200, 500, 1000, 2000, 10000, measurementPeriod, defaultMeasurementPeriod, minMeasurementPeriod] + .filter(n => n >= minMeasurementPeriod) + .filter((item, pos, self) => self.indexOf(item) === pos); + result.sort((a, b) => b - a); + return result; + }, [timeSeriesCapabilities]); const clearSelectDevice = () => { selectDevice.current = undefined; @@ -90,6 +128,11 @@ export const SensorComponent: React.FC = ({sensor, manual clearSelectDevice(); }; + const handleMeasurementPeriodChange = (e: React.ChangeEvent) => { + const newPeriod = parseInt(e.target.value, 10); + setTimeSeriesMeasurementPeriod?.(newPeriod); + }; + const connect = () => sensor.connect({ onDevicesFound: ({devices, select, cancel}) => { setDevicesFound(devices); @@ -160,17 +203,12 @@ export const SensorComponent: React.FC = ({sensor, manual return
; } - const {measurementPeriod, measurement, units, minValue, maxValue} = timeSeriesCapabilities; + const {measurementPeriod, measurement, units} = timeSeriesCapabilities; const valueKey = timeSeriesCapabilities.valueKey as keyof ISensorValues; const sampleRate = measurementPeriod / 1000; - const maxSamples = sampleRate * MaxNumberOfTimeSeriesValues; + const maxSampleTime = sampleRate * MaxNumberOfTimeSeriesValues; const value = values[valueKey]; const displayValue = value !== undefined ? value.toFixed(1) : "--"; - while (latestValuesRef.current.length > maxTimeSeriesValues) { - latestValuesRef.current.shift(); - } - latestValuesRef.current.push({value: value ?? 0, time: 0}); - latestValuesRef.current[0].capabilities = timeSeriesCapabilities; return (
@@ -180,8 +218,8 @@ export const SensorComponent: React.FC = ({sensor, manual width={25} height={50} values={latestValuesRef.current} - minNumTimeSeriesValues={maxTimeSeriesValues} - maxNumTimeSeriesValues={maxTimeSeriesValues} + minTime={0} + maxTime={maxTime} showAxes={true} redrawSignal={Date.now()} /> @@ -195,9 +233,21 @@ export const SensorComponent: React.FC = ({sensor, manual
-
-
Samples: {sampleRate}/sec
-
Max Time: {maxSamples} secs
+
+
+
+
Samples:
+
+ +
+
+
+
Max Time:
+
{maxSampleTime} secs
+
+
); diff --git a/src/sensors/devices/gdx-sensor.ts b/src/sensors/devices/gdx-sensor.ts index 7a1d813..65f1889 100644 --- a/src/sensors/devices/gdx-sensor.ts +++ b/src/sensors/devices/gdx-sensor.ts @@ -4,7 +4,6 @@ import { ISensorCapabilities, ISensorValues, ITimeSeriesCapabilities } from "../ import { IDataTableTimeData } from "../../shared/components/data-table-field"; const goDirectServiceUUID = "d91714ef-28b9-4f91-ba16-f0d9a604f112"; -const defaultMeasurementPeriod = 100; // NOTE: to add a new sensor using the existing global capabilities add the prefix to the array // and update the mapping of the sensor name to the capability below. More work will be @@ -96,13 +95,18 @@ export class GDXSensorDevice extends Device { return; } + let defaultMeasurementPeriod = 50; const firstSensor = gdxDevice?.sensors[0]; if (firstSensor) { const measurementInfo = firstSensor.specs?.measurementInfo; const measurement: string = firstSensor.name; const valueKey = measurement.toLowerCase(); + const minMeasurementPeriod = gdxDevice.minMeasurementPeriod; + defaultMeasurementPeriod = Math.max(minMeasurementPeriod, defaultMeasurementPeriod); this._timeSeriesCapabilities = { measurementPeriod: defaultMeasurementPeriod, + minMeasurementPeriod, + defaultMeasurementPeriod, measurement, valueKey, units: firstSensor.unit, diff --git a/src/sensors/mock-sensor.ts b/src/sensors/mock-sensor.ts index 08431e9..1ec534d 100644 --- a/src/sensors/mock-sensor.ts +++ b/src/sensors/mock-sensor.ts @@ -133,8 +133,11 @@ export class MockSensor extends Sensor { } public get timeSeriesCapabilities(): ITimeSeriesCapabilities { + const defaultMeasurementPeriod = 50; return { - measurementPeriod: 100, + measurementPeriod: defaultMeasurementPeriod, + minMeasurementPeriod: 10, + defaultMeasurementPeriod, measurement: "Fake Value", valueKey: "timeSeries", units: "N/A", diff --git a/src/sensors/sensor.ts b/src/sensors/sensor.ts index 10e1256..0bb6fe0 100644 --- a/src/sensors/sensor.ts +++ b/src/sensors/sensor.ts @@ -80,6 +80,8 @@ export interface IConnectOptions { export interface ITimeSeriesCapabilities { measurementPeriod: number; + minMeasurementPeriod: number; + defaultMeasurementPeriod: number; measurement: string; valueKey: string; units: string; @@ -96,7 +98,7 @@ export class Sensor extends EventEmitter { private _experimentFilters: BluetoothRequestDeviceFilter[]; private _capabilities: ISensorCapabilities; private pollTimeout: number; - private pollInterval: number; + private _pollInterval: number; private error: any; constructor(options: ISensorOptions) { @@ -105,7 +107,7 @@ export class Sensor extends EventEmitter { this._capabilities = options.capabilities; this._connected = false; this._values = {}; - this.pollInterval = options.pollInterval || 1000; + this._pollInterval = options.pollInterval || 1000; this.error = undefined; } @@ -121,6 +123,10 @@ export class Sensor extends EventEmitter { return undefined; // set in device } + public get pollInterval() { + return this._pollInterval; + } + public get connected() { return this._connected; } @@ -193,7 +199,7 @@ export class Sensor extends EventEmitter { const createTimeout = () => { this.pollTimeout = window.setTimeout(() => { this.sendData(options).then(() => this.poll({firstPoll: false})); - }, this.pollInterval); + }, this._pollInterval); }; if (options.firstPoll) { diff --git a/src/shared/components/data-table-field.tsx b/src/shared/components/data-table-field.tsx index d9b0532..314779a 100644 --- a/src/shared/components/data-table-field.tsx +++ b/src/shared/components/data-table-field.tsx @@ -13,9 +13,10 @@ import { useSensor } from "../../mobile-app/hooks/use-sensor"; import { tableKeyboardNav } from "../utils/table-keyboard-nav"; import { confirm, alert } from "../utils/dialogs"; import { handleSpecialValue, isFunctionSymbol } from "../utils/handle-special-value"; -import css from "./data-table-field.module.scss"; import DataTableSparkGraph from "./data-table-sparkgraph"; +import css from "./data-table-field.module.scss"; + const defPrecision = 2; // Schema that is accepted by this component. @@ -179,9 +180,9 @@ export const DataTableField: React.FC = props => { const waitForSensorIntervalRef = useRef(0); const stopTimeSeriesFnRef = useRef<(() => void)|undefined>(undefined); const timeSeriesRecordingRowRef = useRef(undefined); - const maxNumTimeSeriesValues = useMemo(() => { + const maxTime = useMemo(() => { const result = isTimeSeries ? formData.reduce((acc, {timeSeries}) => { - return Math.max(acc, Array.isArray(timeSeries) ? timeSeries.length : 0); + return Math.max(acc, Array.isArray(timeSeries) ? timeSeries[timeSeries.length - 1].time : 0); }, 0) : 0; return result; }, [isTimeSeries, formData]); @@ -200,7 +201,7 @@ export const DataTableField: React.FC = props => { waitForSensorIntervalRef.current = setInterval(() => { const result = sensor.timeSeriesCapabilities; if (result) { - setTimeSeriesCapabilities(result); + setTimeSeriesCapabilities({...result}); clearInterval(waitForSensorIntervalRef.current); } }); @@ -209,6 +210,10 @@ export const DataTableField: React.FC = props => { } }, [sensor, sensorOutput.connected, isTimeSeries, setTimeSeriesCapabilities]); + const setTimeSeriesMeasurementPeriod = (newPeriod: number) => { + setTimeSeriesCapabilities(prev => prev ? {...prev, measurementPeriod: newPeriod} : prev); + }; + const sensorCanRecord = useMemo(() => { return sensorOutput.connected && Object.values(sensorOutput.values).length > 0; }, [sensorOutput]); @@ -345,11 +350,11 @@ export const DataTableField: React.FC = props => { }; const recordTimeSeries = () => { - if (!sensor) { + if (!sensor || !timeSeriesCapabilities) { return; } - stopTimeSeriesFnRef.current = sensor.collectTimeSeries(100, (values) => { + stopTimeSeriesFnRef.current = sensor.collectTimeSeries(timeSeriesCapabilities.measurementPeriod, (values) => { const newData = formData.slice(); newData[rowIdx] = {timeSeries: values}; if (values.length <= MaxNumberOfTimeSeriesValues) { @@ -487,12 +492,8 @@ export const DataTableField: React.FC = props => { let contents; if (name === "timeSeries") { - let graphTitle = ""; - const values= value || []; - if (timeSeriesCapabilities) { - const duration = Math.round((timeSeriesCapabilities.measurementPeriod / 1000) * values.length); - graphTitle = `${duration} sec`; - } + const values: IDataTableTimeData[] = value || []; + const graphTitle = values.length > 0 ? `${Math.round(values[values.length - 1].time)} sec` : ""; contents =
@@ -501,7 +502,7 @@ export const DataTableField: React.FC = props => { width={200} height={30} values={value || []} - maxNumTimeSeriesValues={maxNumTimeSeriesValues} + maxTime={maxTime} />
; } else if (readOnly) { @@ -541,7 +542,16 @@ export const DataTableField: React.FC = props => {
- {showSensor && sensor ? : undefined} + {showSensor && sensor + ? + : undefined} {title ?
{title}
: undefined}
diff --git a/src/shared/components/data-table-sparkgraph.tsx b/src/shared/components/data-table-sparkgraph.tsx index 90e7429..a67a346 100644 --- a/src/shared/components/data-table-sparkgraph.tsx +++ b/src/shared/components/data-table-sparkgraph.tsx @@ -16,13 +16,13 @@ interface IProps { width: number; height: number; values: IDataTableTimeData[]; - maxNumTimeSeriesValues: number; - minNumTimeSeriesValues?: number; + maxTime: number; + minTime?: number; showAxes?: boolean; redrawSignal?: number; } -export default function DataTableSparkGraph({width, height, values, maxNumTimeSeriesValues, minNumTimeSeriesValues, showAxes, redrawSignal}: IProps) { +export default function DataTableSparkGraph({width, height, values, minTime, maxTime, showAxes, redrawSignal}: IProps) { const innerWidth = width - (leaderMargin * 2); const innerHeight = height - (leaderMargin * 2); const innerLeft = leaderMargin; @@ -33,7 +33,7 @@ export default function DataTableSparkGraph({width, height, values, maxNumTimeSe const polyLinePointsRef = useRef([]); const polygonPointsRef = useRef([]); const leaderPointRef = useRef(undefined); - const lastMaxNumTimeSeriesValuesRef = useRef(maxNumTimeSeriesValues); + const lastMaxTimeRef = useRef(maxTime); const lastRedrawSignalRef = useRef(redrawSignal); let polyLinePoints = polyLinePointsRef.current; @@ -42,21 +42,20 @@ export default function DataTableSparkGraph({width, height, values, maxNumTimeSe // instead of relying on the values changing just compare the last number of points displayed or if we are signaled to redraw const graphChanged = (polyLinePoints.length !== values.length) - || (maxNumTimeSeriesValues !== lastMaxNumTimeSeriesValuesRef.current) + || (maxTime !== lastMaxTimeRef.current) || (redrawSignal !== lastRedrawSignalRef.current); if (graphChanged) { const capabilities = values[0]?.capabilities; const minValue = capabilities?.minValue ?? values.reduce((acc, cur) => Math.min(cur.value, acc), Infinity); const maxValue = capabilities?.maxValue ?? values.reduce((acc, cur) => Math.max(cur.value, acc), -Infinity); - const maxNumValues = Math.max(maxNumTimeSeriesValues, (minNumTimeSeriesValues ?? 100)); let x: number = 0; let y: number = 0; - polyLinePoints = values.map(({value}, index) => { + polyLinePoints = values.map(({value, time}) => { value = Math.max(minValue, Math.min(value, maxValue)); - x = innerLeft + (innerWidth * (index / maxNumValues)); + x = innerLeft + (innerWidth * (time / Math.max(maxTime, (minTime ?? 10)))); y = innerLeft + (innerHeight - (((value - minValue) / (maxValue - minValue)) * innerHeight)); return `${x},${y}`; }); @@ -69,7 +68,7 @@ export default function DataTableSparkGraph({width, height, values, maxNumTimeSe polyLinePointsRef.current = polyLinePoints; polygonPointsRef.current = polygonPoints; - lastMaxNumTimeSeriesValuesRef.current = maxNumTimeSeriesValues; + lastMaxTimeRef.current = maxTime; lastRedrawSignalRef.current = redrawSignal; }