From 26c388d2d2ee84926c7bff2104d58bbd64695ee4 Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Mon, 1 Apr 2024 15:52:36 -0700 Subject: [PATCH 01/20] =?UTF-8?q?=F0=9F=93=8B=20Prep=20for=20saving=20new?= =?UTF-8?q?=20BLE=20Bluetooth=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the major and minor boxes to the same row to take up less space - Split the fake callbacks into enter, exit and range - generalize `fakeMonitorCallback` to work for both enter and exit - do not call rangeCallback directly from monitor callback since there are now separate buttons - for the range callback, use the device major and minor values if they exist - for the range callback, reset monitor and range results to avoid duplicates This is the first step for testing the data models defined in: https://github.com/e-mission/e-mission-docs/issues/1062#issuecomment-2027849093 --- www/js/bluetooth/BluetoothCard.tsx | 28 +++++++++++++++++--------- www/js/bluetooth/BluetoothScanPage.tsx | 22 +++++++++++--------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/www/js/bluetooth/BluetoothCard.tsx b/www/js/bluetooth/BluetoothCard.tsx index e212121a2..561bf9045 100644 --- a/www/js/bluetooth/BluetoothCard.tsx +++ b/www/js/bluetooth/BluetoothCard.tsx @@ -23,7 +23,7 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { bgColor = device.in_range ? `rgba(200,250,200,1)` : `rgba(250,200,200,1)`; } - async function fakeMonitorCallback() { + async function fakeMonitorCallback(state: String) { // If we don't do this, the results start accumulating in the device object // first call, we put a result into the device // second call, the device already has a result, so we put another one in... @@ -33,16 +33,16 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { window['cordova'].plugins.locationManager.getDelegate().didDetermineStateForRegion({ region: deviceWithoutResult, eventType: 'didDetermineStateForRegion', - state: 'CLRegionStateInside', + state: state, }); - let timer: ReturnType = setTimeout(fakeRangeCallback, 500); } async function fakeRangeCallback() { - // If we don't do this, the results start accumulating in the device object - // first call, we put a result into the device - // second call, the device already has a result, so we put another one in... - const deviceWithMajorMinor = { ...device, major: 1234, minor: 4567 }; + const deviceWithMajorMinor = { ...device }; + deviceWithMajorMinor.major = device.major | 1234; + deviceWithMajorMinor.minor = device.minor | 4567; + deviceWithMajorMinor.monitorResult = undefined; + deviceWithMajorMinor.rangeResult = undefined; window['cordova'].plugins.locationManager.getDelegate().didRangeBeaconsInRegion({ region: deviceWithMajorMinor, eventType: 'didRangeBeaconsInRegion', @@ -65,9 +65,17 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { {device.rangeResult} - + + + + + ); diff --git a/www/js/bluetooth/BluetoothScanPage.tsx b/www/js/bluetooth/BluetoothScanPage.tsx index bb96943c7..6e2387ad3 100644 --- a/www/js/bluetooth/BluetoothScanPage.tsx +++ b/www/js/bluetooth/BluetoothScanPage.tsx @@ -317,16 +317,18 @@ const BluetoothScanPage = ({ ...props }: any) => { value={newUUID || ''} onChangeText={(t) => setNewUUID(t.toUpperCase())} /> - setNewMajor(t)} - /> - setNewMinor(t)} - /> + + setNewMajor(t)} + /> + setNewMinor(t)} + /> + From 5f53e5301e39ea53717f6ea3ac4b030b053c03fe Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 2 Apr 2024 23:53:12 -0400 Subject: [PATCH 02/20] include 'sections' in UnprocessedTrips To be consistent with processed trips, and so we can use them in the same way, unprocessed trips should have the 'sections' property. Unprocessed trips / 'draft' trips are assumed to be unimodal, so we can fairly easily reconstruct a section spanning the entirety of the trip and include that as the only section. In doing so, I modified points2TripProps. I realized that many of the props will actually be the same between the trip and the section. So I did a bit of refactoring; to construct the unprocessed trip I first construct baseProps (which are used in both the section and the trip); then I construct the section; then finally I construct the trip in the return statement. Something else I noticed is that all the trip props are computed in points2TripProps except the start_loc and end_loc which were computed in the outer function transitionTrip2TripObj. Since we already have variables for the start and end coordinates in the points2TripProps function, it seems more logical to handle start_loc and end_loc there with the rest of the trip props. Then we can declare the return type of points2TripProps as UnprocessedTrip; it has all the required properties now. --- www/js/diary/timelineHelper.ts | 62 +++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index f140f1750..81c32956a 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -14,6 +14,7 @@ import { TimestampRange, CompositeTrip, UnprocessedTrip, + SectionData, } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; import { LabelOptions } from '../types/labelTypes'; @@ -288,7 +289,7 @@ const dateTime2localdate = (currtime: DateTime, tz: string) => ({ second: currtime.second, }); -function points2TripProps(locationPoints: Array>) { +function points2TripProps(locationPoints: Array>): UnprocessedTrip { const startPoint = locationPoints[0]; const endPoint = locationPoints[locationPoints.length - 1]; const tripAndSectionId = `unprocessed_${startPoint.data.ts}_${endPoint.data.ts}`; @@ -318,24 +319,51 @@ function points2TripProps(locationPoints: Array>) { speed: speeds[i], })); - return { - _id: { $oid: tripAndSectionId }, - key: 'UNPROCESSED_trip', - origin_key: 'UNPROCESSED_trip', - additions: [], - confidence_threshold: 0, + // baseProps: these are the properties that are the same between the trip and its section + const baseProps = { distance: dists.reduce((a, b) => a + b, 0), duration: endPoint.data.ts - startPoint.data.ts, end_fmt_time: endTime.toISO() || displayErrorMsg('end_fmt_time: invalid DateTime') || '', + end_loc: { + type: 'Point', + coordinates: [endPoint.data.longitude, endPoint.data.latitude], + } as Point, end_local_dt: dateTime2localdate(endTime, endPoint.metadata.time_zone), end_ts: endPoint.data.ts, - expectation: { to_label: true }, - inferred_labels: [], - locations: locations, source: 'unprocessed', start_fmt_time: startTime.toISO() || displayErrorMsg('start_fmt_time: invalid DateTime') || '', + start_loc: { + type: 'Point', + coordinates: [startPoint.data.longitude, startPoint.data.latitude], + } as Point, start_local_dt: dateTime2localdate(startTime, startPoint.metadata.time_zone), start_ts: startPoint.data.ts, + }; + + // section: baseProps + some properties that are unique to the section + const singleSection: SectionData = { + ...baseProps, + _id: { $oid: `unprocessed_section_${tripAndSectionId}` }, + cleaned_section: { $oid: `unprocessed_section_${tripAndSectionId}` }, + key: 'UNPROCESSED_section', + origin_key: 'UNPROCESSED_section', + sensed_mode: 4, // MotionTypes.UNKNOWN (4) + sensed_mode_str: 'UNKNOWN', + trip_id: { $oid: tripAndSectionId }, + }; + + // the complete UnprocessedTrip: baseProps + properties that are unique to the trip, including the section + return { + ...baseProps, + _id: { $oid: tripAndSectionId }, + additions: [], + confidence_threshold: 0, + expectation: { to_label: true }, + inferred_labels: [], + key: 'UNPROCESSED_trip', + locations: locations, + origin_key: 'UNPROCESSED_trip', + sections: [singleSection], user_input: {}, }; } @@ -386,19 +414,7 @@ function transitionTrip2TripObj(trip: Array): Promise Date: Wed, 3 Apr 2024 00:39:36 -0400 Subject: [PATCH 03/20] update types relating to `UnprocessedTrip`s The main thing I was doing in this commit was adding 'sections' to the type signature of `UnprocessedTrip`. But I also noticed some other things amiss. `UnprocessedTrip` was missing some other properties; end_ts and start_fmt_time "LocationCoord" is not needed as it's the same as `Point` from 'geojson'. `SectionData` was missing a bunch of properties. Once those are filled in, the 'sections' property in `CompositeTrip` and `UnprocessedTrip` can be typed as `SectionData[]` --- www/js/types/diaryTypes.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 75c43b2d6..12102477d 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -45,11 +45,6 @@ export type TripTransition = { ts: number; }; -export type LocationCoord = { - type: string; // e.x., "Point" - coordinates: [number, number]; -}; - type CompTripLocations = { loc: { coordinates: number[]; // e.g. [1, 2.3] @@ -68,12 +63,15 @@ export type UnprocessedTrip = { end_fmt_time: string; end_loc: Point; end_local_dt: LocalDt; + end_ts: number; expectation: any; // TODO "{to_label: boolean}" inferred_labels: any[]; // TODO key: string; locations?: CompTripLocations[]; origin_key: string; // e.x., UNPROCESSED_trip + sections: SectionData[]; source: string; + start_fmt_time: string; start_local_dt: LocalDt; start_ts: number; start_loc: Point; @@ -107,7 +105,7 @@ export type CompositeTrip = { locations: any[]; // TODO origin_key: string; raw_trip: ObjectId; - sections: any[]; // TODO + sections: SectionData[]; source: string; start_confirmed_place: BEMData; start_fmt_time: string; @@ -188,23 +186,25 @@ export type Location = { latitude: number; fmt_time: string; // ISO mode: number; - loc: LocationCoord; + loc: Point; ts: number; // Unix altitude: number; distance: number; }; -// used in readAllCompositeTrips export type SectionData = { + _id: ObjectId; end_ts: number; // Unix time, e.x. 1696352498.804 - end_loc: LocationCoord; + end_loc: Point; start_fmt_time: string; // ISO time end_fmt_time: string; + key: string; + origin_key: string; trip_id: ObjectId; sensed_mode: number; source: string; // e.x., "SmoothedHighConfidenceMotion" start_ts: number; // Unix - start_loc: LocationCoord; + start_loc: Point; cleaned_section: ObjectId; start_local_dt: LocalDt; end_local_dt: LocalDt; @@ -213,7 +213,7 @@ export type SectionData = { distance: number; }; -// used in timelineHelper's `transitionTrip2TripObj` +// used in timelineHelper's `transitionTrip2UnprocessedTrip` export type FilteredLocation = { accuracy: number; altitude: number; From 12db91a30c1f3d0c1b852a2efe1e4512c34d9a5f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 3 Apr 2024 00:47:12 -0400 Subject: [PATCH 04/20] rename functions related to unprocessed trips After a bunch of refactoring, I think these functions could use a naming update. I always found this part of the codebase fairly opaque anyway and I think it can now be made more easily comprehensible. 'points2TripProps' now returns a full UnprocessedTrip object so it is renamed 'points2UnprocessedTrip' And 'transitions2Trips' is renamed 'transitions2TripTransitions' because it doesn't really return trip objects; it returns pairs of transitions that represent trips. To follow suit, 'transitionTrip2TripObj' is renamed 'tripTransitions2UnprocessedTrip'. Added a bit of JSDoc to help clarify what these functions do. --- www/js/diary/timelineHelper.ts | 52 ++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 81c32956a..006cc0788 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -289,7 +289,10 @@ const dateTime2localdate = (currtime: DateTime, tz: string) => ({ second: currtime.second, }); -function points2TripProps(locationPoints: Array>): UnprocessedTrip { +/** + * @description Given an array of location points, creates an UnprocessedTrip object. + */ +function points2UnprocessedTrip(locationPoints: Array>): UnprocessedTrip { const startPoint = locationPoints[0]; const endPoint = locationPoints[locationPoints.length - 1]; const tripAndSectionId = `unprocessed_${startPoint.data.ts}_${endPoint.data.ts}`; @@ -371,7 +374,11 @@ function points2TripProps(locationPoints: Array>): Unp const tsEntrySort = (e1: BEMData, e2: BEMData) => e1.data.ts - e2.data.ts; // compare timestamps -function transitionTrip2TripObj(trip: Array): Promise { +/** + * @description Given an array of 2 transitions, queries the location data during that time and promises an UnprocessedTrip object. + * @param trip An array of transitions representing one trip; i.e. [start transition, end transition] + */ +function tripTransitions2UnprocessedTrip(trip: Array): Promise { const tripStartTransition = trip[0]; const tripEndTransition = trip[1]; const tq = { @@ -413,8 +420,7 @@ function transitionTrip2TripObj(trip: Array): Promise) { // Logger.log("Returning false"); return false; } -/* - * This is going to be a bit tricky. As we can see from - * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163, - * when we read local transitions, they have a string for the transition - * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer - * (e.g. `2`). - * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606 - * - * Also, at least on iOS, it is possible for trip end to be detected way - * after the end of the trip, so the trip end transition of a processed - * trip may actually show up as an unprocessed transition. - * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163 - * - * Let's abstract this out into our own minor state machine. + +/** + * @description Given an array of transitions, finds which transitions represent the start and end of a detected trip and returns them as pairs. + * @returns An 2D array of transitions, where each inner array represents one trip; i.e. [start transition, end transition] */ -function transitions2Trips(transitionList: Array>) { +function transitions2TripTransitions(transitionList: Array>) { + /* This is going to be a bit tricky. As we can see from + * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163, + * when we read local transitions, they have a string for the transition + * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer + * (e.g. `2`). + * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606 + * + * Also, at least on iOS, it is possible for trip end to be detected way + * after the end of the trip, so the trip end transition of a processed + * trip may actually show up as an unprocessed transition. + * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163 + * + * Let's abstract this out into our own minor state machine. + */ let inTrip = false; const tripList: [BEMData, BEMData][] = []; let currStartTransitionIndex = -1; @@ -536,12 +546,12 @@ export function readUnprocessedTrips( return []; } else { logDebug(`Found ${transitionList.length} transitions. yay!`); - const tripsList = transitions2Trips(transitionList); + const tripsList = transitions2TripTransitions(transitionList); logDebug(`Mapped into ${tripsList.length} trips. yay!`); tripsList.forEach((trip) => { logDebug(JSON.stringify(trip, null, 2)); }); - const tripFillPromises = tripsList.map(transitionTrip2TripObj); + const tripFillPromises = tripsList.map(tripTransitions2UnprocessedTrip); return Promise.all(tripFillPromises).then( (rawTripObjs: (UnprocessedTrip | undefined)[]) => { // Now we need to link up the trips. linking unprocessed trips From 826190a0f842a61c205fb206ef11d8939d8023d0 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 3 Apr 2024 01:29:03 -0400 Subject: [PATCH 05/20] flesh out more types Filled in more trip object types, and specifically some literal types that define unprocessed trips. (like "source" will always be 'unprocessed'). CompTripLocations changed from `number[]` for coordinates to geojson's `Position`, since that is more descriptive. Renamed CompTripLocations to CompositeTripLocation (this type represents only 1 location). Used the CompositeTripLocation type in timelineHelper. in locations2GeojsonTrajectory, the return type needed `properties` for it to be considered a Geojson `Feature`. formattedSectionProperties types as the return type of the function --- www/js/diary/timelineHelper.ts | 12 ++++++++---- www/js/types/diaryTypes.ts | 26 +++++++++++++------------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 006cc0788..6c87cd0b9 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -15,6 +15,7 @@ import { CompositeTrip, UnprocessedTrip, SectionData, + CompositeTripLocation, } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; import { LabelOptions } from '../types/labelTypes'; @@ -216,10 +217,10 @@ const location2GeojsonPoint = (locationPoint: Point, featureType: string): Featu */ function locations2GeojsonTrajectory( trip: CompositeTrip, - locationList: Array, + locationList: CompositeTripLocation[], trajectoryColor?: string, -) { - let sectionsPoints; +): Feature[] { + let sectionsPoints: CompositeTripLocation[][]; if (!trip.sections) { // this is a unimodal trip so we put all the locations in one section sectionsPoints = [locationList]; @@ -243,6 +244,9 @@ function locations2GeojsonTrajectory( color for the sensed mode of this section, and fall back to dark grey */ color: trajectoryColor || getBaseModeByKey(section?.sensed_mode_str)?.color || '#333', }, + properties: { + feature_type: 'section_trajectory', + }, }; }); } @@ -341,7 +345,7 @@ function points2UnprocessedTrip(locationPoints: Array> } as Point, start_local_dt: dateTime2localdate(startTime, startPoint.metadata.time_zone), start_ts: startPoint.data.ts, - }; + } as const; // section: baseProps + some properties that are unique to the section const singleSection: SectionData = { diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 12102477d..8b8de469c 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -5,7 +5,7 @@ import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; import { MultilabelKey } from './labelTypes'; import { BEMData, LocalDt } from './serverData'; -import { FeatureCollection, Feature, Geometry, Point } from 'geojson'; +import { FeatureCollection, Feature, Geometry, Point, Position } from 'geojson'; type ObjectId = { $oid: string }; @@ -45,9 +45,9 @@ export type TripTransition = { ts: number; }; -type CompTripLocations = { +export type CompositeTripLocation = { loc: { - coordinates: number[]; // e.g. [1, 2.3] + coordinates: Position; // [lon, lat] }; speed: number; ts: number; @@ -56,7 +56,7 @@ type CompTripLocations = { // Used for return type of readUnprocessedTrips export type UnprocessedTrip = { _id: ObjectId; - additions: UserInputEntry[]; + additions: []; // unprocessed trips won't have any matched processed inputs, so this is always empty confidence_threshold: number; distance: number; duration: number; @@ -64,19 +64,19 @@ export type UnprocessedTrip = { end_loc: Point; end_local_dt: LocalDt; end_ts: number; - expectation: any; // TODO "{to_label: boolean}" - inferred_labels: any[]; // TODO - key: string; - locations?: CompTripLocations[]; - origin_key: string; // e.x., UNPROCESSED_trip + expectation: { to_label: true }; // unprocessed trips are always expected to be labeled + inferred_labels: []; // unprocessed trips won't have inferred labels + key: 'UNPROCESSED_trip'; + locations?: CompositeTripLocation[]; + origin_key: 'UNPROCESSED_trip'; sections: SectionData[]; - source: string; + source: 'unprocessed'; start_fmt_time: string; start_local_dt: LocalDt; start_ts: number; start_loc: Point; starting_trip?: any; - user_input: UserInput; + user_input: {}; // unprocessed trips won't have any matched processed inputs, so this is always empty }; /* These are the properties received from the server (basically matches Python code) @@ -96,13 +96,13 @@ export type CompositeTrip = { end_local_dt: LocalDt; end_place: ObjectId; end_ts: number; - expectation: any; // TODO "{to_label: boolean}" + expectation: { to_label: boolean }; expected_trip: ObjectId; inferred_labels: InferredLabels; inferred_section_summary: SectionSummary; inferred_trip: ObjectId; key: string; - locations: any[]; // TODO + locations: CompositeTripLocation[]; origin_key: string; raw_trip: ObjectId; sections: SectionData[]; From ba3ef4365c655080825950ee820c4f59869cdf2b Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Thu, 11 Apr 2024 09:42:56 -0700 Subject: [PATCH 06/20] Fill in a "beacons" array for the range callback The array has randomly generated accuracy and RSSI values Further, when we exit the region, all the results are undefined This is part of the change to test the data model defined in https://github.com/e-mission/e-mission-docs/issues/1062#issuecomment-2027849093 --- www/js/bluetooth/BluetoothCard.tsx | 22 +++++++++++++++------- www/js/bluetooth/BluetoothScanPage.tsx | 3 ++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/www/js/bluetooth/BluetoothCard.tsx b/www/js/bluetooth/BluetoothCard.tsx index 561bf9045..d3361badc 100644 --- a/www/js/bluetooth/BluetoothCard.tsx +++ b/www/js/bluetooth/BluetoothCard.tsx @@ -38,13 +38,21 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { } async function fakeRangeCallback() { - const deviceWithMajorMinor = { ...device }; - deviceWithMajorMinor.major = device.major | 1234; - deviceWithMajorMinor.minor = device.minor | 4567; - deviceWithMajorMinor.monitorResult = undefined; - deviceWithMajorMinor.rangeResult = undefined; + const deviceWithBeacons = { ...device }; + deviceWithBeacons.monitorResult = undefined; + deviceWithBeacons.rangeResult = undefined; + deviceWithBeacons.beacons = [ + {uuid: device.uuid, + major: device.major | 4567, + minor: device.minor | 1945, + proximity: "ProximityNear", + accuracy: Math.random() * 1.33, + rssi: Math.random() * -62} + ] + deviceWithBeacons.minor = device.minor | 4567; + deviceWithBeacons.minor = device.minor | 4567; window['cordova'].plugins.locationManager.getDelegate().didRangeBeaconsInRegion({ - region: deviceWithMajorMinor, + region: deviceWithBeacons, eventType: 'didRangeBeaconsInRegion', state: 'CLRegionStateInside', }); @@ -65,7 +73,7 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { {device.rangeResult} - + diff --git a/www/js/bluetooth/BluetoothScanPage.tsx b/www/js/bluetooth/BluetoothScanPage.tsx index 6e2387ad3..435a7c4de 100644 --- a/www/js/bluetooth/BluetoothScanPage.tsx +++ b/www/js/bluetooth/BluetoothScanPage.tsx @@ -90,7 +90,8 @@ const BluetoothScanPage = ({ ...props }: any) => { ...prevDevices, [uuid]: { ...prevDevices[uuid], - monitorResult: result, + monitorResult: status? result : undefined, + rangeResult: undefined, in_range: status, }, })); From 6ca810d2927d94f069bf8a6c4234445b89a5cabe Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Thu, 11 Apr 2024 10:21:19 -0700 Subject: [PATCH 07/20] Prettier fixes + highlight that the buttons simulate behavior The simulation buttons are white text on a red background --- www/js/bluetooth/BluetoothCard.tsx | 17 ++++++++++++----- www/js/bluetooth/BluetoothScanPage.tsx | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/www/js/bluetooth/BluetoothCard.tsx b/www/js/bluetooth/BluetoothCard.tsx index d3361badc..8675f8179 100644 --- a/www/js/bluetooth/BluetoothCard.tsx +++ b/www/js/bluetooth/BluetoothCard.tsx @@ -42,13 +42,15 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { deviceWithBeacons.monitorResult = undefined; deviceWithBeacons.rangeResult = undefined; deviceWithBeacons.beacons = [ - {uuid: device.uuid, + { + uuid: device.uuid, major: device.major | 4567, minor: device.minor | 1945, - proximity: "ProximityNear", + proximity: 'ProximityNear', accuracy: Math.random() * 1.33, - rssi: Math.random() * -62} - ] + rssi: Math.random() * -62, + }, + ]; deviceWithBeacons.minor = device.minor | 4567; deviceWithBeacons.minor = device.minor | 4567; window['cordova'].plugins.locationManager.getDelegate().didRangeBeaconsInRegion({ @@ -73,7 +75,12 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { {device.rangeResult} - + + Simulate by sending UI transitions + + diff --git a/www/js/bluetooth/BluetoothScanPage.tsx b/www/js/bluetooth/BluetoothScanPage.tsx index 435a7c4de..d94b01597 100644 --- a/www/js/bluetooth/BluetoothScanPage.tsx +++ b/www/js/bluetooth/BluetoothScanPage.tsx @@ -90,7 +90,7 @@ const BluetoothScanPage = ({ ...props }: any) => { ...prevDevices, [uuid]: { ...prevDevices[uuid], - monitorResult: status? result : undefined, + monitorResult: status ? result : undefined, rangeResult: undefined, in_range: status, }, From 7cb388227e52369725ab4c20c5692d433dbc6417 Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Fri, 12 Apr 2024 09:19:31 -0700 Subject: [PATCH 08/20] =?UTF-8?q?=E2=9C=A8=20Simulate=20BLE=20entries=20an?= =?UTF-8?q?d=20transitions=20through=20the=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistent with the plans in: https://github.com/e-mission/e-mission-docs/issues/1062#issuecomment-2050747991 add support for storing simulated BLE responses and transitions in the usercache Concretely: - when there is a monitor entry event, we store a `bluetooth_ble` entry with `REGION_ENTER` - when there is a range event, we store a `bluetooth_ble` entry with `RANGE_UPDATE`. if we receive three consecutive range updates within 5 minutes, we generate a `BLE_BEACON_FOUND` transition - when there is a monitor exit event, we store a `bluetooth_ble` entry with `REGION_EXIT` and generate a `BLE_BEACON_LOST` transition Note that this has some fixes found while testing android: https://github.com/e-mission/e-mission-docs/issues/1062#issuecomment-2052071983 In addition, the callbacks exposed that the format of the range callback that we were using earlier was incorrect. The `beacons` array is at the same level as the `region` Testing done on both iOS and android: - Scan for BLE beacons - Region enter - Range (3-4 times); see `ble_found` transition - Region exit; see `ble_lost` transition --- www/js/bluetooth/BluetoothCard.tsx | 9 +- www/js/bluetooth/BluetoothScanPage.tsx | 112 ++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 16 deletions(-) diff --git a/www/js/bluetooth/BluetoothCard.tsx b/www/js/bluetooth/BluetoothCard.tsx index 8675f8179..a244be7cf 100644 --- a/www/js/bluetooth/BluetoothCard.tsx +++ b/www/js/bluetooth/BluetoothCard.tsx @@ -41,7 +41,7 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { const deviceWithBeacons = { ...device }; deviceWithBeacons.monitorResult = undefined; deviceWithBeacons.rangeResult = undefined; - deviceWithBeacons.beacons = [ + const beacons = [ { uuid: device.uuid, major: device.major | 4567, @@ -55,6 +55,7 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { deviceWithBeacons.minor = device.minor | 4567; window['cordova'].plugins.locationManager.getDelegate().didRangeBeaconsInRegion({ region: deviceWithBeacons, + beacons: beacons, eventType: 'didRangeBeaconsInRegion', state: 'CLRegionStateInside', }); @@ -77,18 +78,18 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { + variant="bodyLarge"> Simulate by sending UI transitions diff --git a/www/js/bluetooth/BluetoothScanPage.tsx b/www/js/bluetooth/BluetoothScanPage.tsx index d94b01597..d855c47c3 100644 --- a/www/js/bluetooth/BluetoothScanPage.tsx +++ b/www/js/bluetooth/BluetoothScanPage.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; import { StyleSheet, Modal, ScrollView, SafeAreaView, View, Text } from 'react-native'; import { gatherBluetoothClassicData } from './bluetoothScanner'; import { logWarn, displayError, displayErrorMsg, logDebug } from '../plugin/logger'; @@ -11,6 +12,7 @@ import { BluetoothClassicDevice, BLEDeviceList, } from '../types/bluetoothDevices'; +import { forceTransition } from '../control/ControlCollectionHelper'; /** * The implementation of this scanner page follows the design of @@ -95,6 +97,18 @@ const BluetoothScanPage = ({ ...props }: any) => { in_range: status, }, })); + // using putMessage instead of putSensorData as a temporary hack for now + // since putSensorData is not exposed through javascript + let { monitorResult: _, in_range: _, ...noResultDevice } = sampleBLEDevices[uuid]; + window['cordova']?.plugins?.BEMUserCache.putMessage('background/bluetooth_ble', { + eventType: status ? 'REGION_ENTER' : 'REGION_EXIT', + ts: Date.now() / 1000, // our convention is to have timestamps in seconds + uuid: uuid, + ...noResultDevice, // gives us uuid, major, minor + }); + if (!status) { + forceTransition('BLE_BEACON_LOST'); + } } function setRangeStatus(uuid: string, result: string) { @@ -105,8 +119,57 @@ const BluetoothScanPage = ({ ...props }: any) => { rangeResult: result, }, })); + // we don't want to exclude monitorResult and rangeResult from the values + // that we save because they are the current or previous result, just + // in a different format + // https://stackoverflow.com/a/34710102 + let { + monitorResult: _, + rangeResult: _, + in_range: _, + ...noResultDevice + } = sampleBLEDevices[uuid]; + let parsedResult = JSON.parse(result); + parsedResult.beacons.forEach((beacon) => { + window['cordova']?.plugins?.BEMUserCache.putMessage('background/bluetooth_ble', { + eventType: 'RANGE_UPDATE', + ts: Date.now() / 1000, // our convention is to have timestamps in seconds + uuid: uuid, + ...noResultDevice, // gives us uuid, major, minor + ...beacon, // gives us proximity, accuracy and rssi + }); + }); + // we only check for the transition on "real" callbacks to avoid excessive + // spurious callbacks on android + if (parsedResult.beacons.length > 0) { + // if we have received 3 range responses for the same beacon in the + // last 5 minutes, we generate the transition. we read without metadata + // (last param) + let nowSec = DateTime.now().toUnixInteger(); + let tq = { key: 'write_ts', startTs: nowSec - 5 * 60, endTs: nowSec }; + let readBLEReadingsPromise = window['cordova']?.plugins?.BEMUserCache.getMessagesForInterval( + 'background/bluetooth_ble', + tq, + false, + ); + readBLEReadingsPromise.then((bleResponses) => { + let lastThreeResponses = bleResponses.slice(0, 3); + if (!lastThreeResponses.every((x) => x.eventType == 'RANGE_UPDATE')) { + console.log( + 'Last three entries ' + + lastThreeResponses.map((x) => x.eventType) + + ' are not all RANGE_UPDATE, skipping transition', + ); + return; + } + + forceTransition('BLE_BEACON_FOUND'); + }); + } } + async function simulateLocation(state: String) {} + // BLE LOGIC async function startBeaconScanning() { setIsScanningBLE(true); @@ -128,18 +191,21 @@ const BluetoothScanPage = ({ ...props }: any) => { window['cordova'].plugins.locationManager.appendToDeviceLog( '[DOM] didDetermineStateForRegion: ' + pluginResultStr, ); - const beaconRegion = new window['cordova'].plugins.locationManager.BeaconRegion( - STATIC_ID, - pluginResult.region.uuid, - pluginResult.region.major, - pluginResult.region.minor, - ); - window['cordova'].plugins.locationManager - .startRangingBeaconsInRegion(beaconRegion) - .fail(function (e) { - logWarn(e); - }) - .done(); + if (pluginResult.state == 'CLRegionStateInside') { + const beaconRegion = new window['cordova'].plugins.locationManager.BeaconRegion( + STATIC_ID, + pluginResult.region.uuid, + pluginResult.region.major, + pluginResult.region.minor, + ); + console.log('About to start ranging beacons for region ', beaconRegion); + window['cordova'].plugins.locationManager + .startRangingBeaconsInRegion(beaconRegion) + .fail(function (e) { + logWarn(e); + }) + .done(); + } }; delegate.didStartMonitoringForRegion = function (pluginResult) { @@ -333,6 +399,28 @@ const BluetoothScanPage = ({ ...props }: any) => { + + + Simulate by sending UI transitions + + + + + + + From 2cc07a0d007a52e31d352858f94620e3cf334bea Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 12 Apr 2024 15:23:37 -0400 Subject: [PATCH 09/20] add type defs for BLE data + new config fields As discussed in https://github.com/e-mission/e-mission-docs/issues/1062 --- www/js/types/appConfigTypes.ts | 22 ++++++++++++++++++++++ www/js/types/diaryTypes.ts | 11 +++++++++++ 2 files changed, 33 insertions(+) diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index 5bfedce03..b97ce2a4d 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -9,6 +9,10 @@ export type AppConfig = { surveys: EnketoSurveyConfig; buttons?: SurveyButtonsConfig; }; + vehicle_identities?: VehicleIdentity[]; + tracking?: { + bluetooth_only: boolean; + }; reminderSchemes?: ReminderSchemesConfig; [k: string]: any; // TODO fill in all the other fields }; @@ -57,6 +61,24 @@ export type SurveyButtonsConfig = { | SurveyButtonConfig[]; }; +export type VehicleIdentity = { + value: string; + bluetooth_major_minor: string[]; // e.g. ['aaaa:bbbb', 'cccc:dddd'] + text: string; + baseMode: string; + met_equivalent: string; + kgCo2PerKm: number; + vehicle_info: { + type: string; + license: string; + make: string; + model: string; + year: number; + color: string; + engine: 'ICE' | 'HEV' | 'PHEV' | 'BEV' | 'HYDROGENV' | 'BIOV'; + }; +}; + export type ReminderSchemesConfig = { [schemeKey: string]: { title: { [lang: string]: string }; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 75c43b2d6..7eb4248e2 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -178,6 +178,17 @@ export type UserInputEntry = { key?: string; }; +export type BluetoothBleData = { + ts: number; + eventType: 'REGION_ENTER' | 'REGION_EXIT' | 'RANGE_UPDATE'; + uuid: string; + major: number; // for our use case, missing for REGION_ENTER or REGION_EXIT + minor: number; // for our use case, missing for REGION_ENTER or REGION_EXIT + proximity?: string; // only available for RANGE_UPDATE + rssi?: string; // only available for RANGE_UPDATE + accuracy?: string; // only available for RANGE_UPDATE +}; + export type Location = { speed: number; heading: number; From a586bb71d76d1fa4447336b740f0bcb55940a349 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 12 Apr 2024 15:33:33 -0400 Subject: [PATCH 10/20] retrieve + match BLE scans to timelineEntries We nearly have the Bluetooth integration done. Now we just need the phone to retrieve BLE scans and implement some matching logic to figure out which beacons go to which trips. in timelineHelper there is a new function to retrieve BLE scans using the unified data loader. in inputMatcher we have new functions that go through each timelineEntry and match it to a beacon. Then it looks up that beacon (by its major:minor pair) in the app config to get the vehicle identity. In LabelTab, we have a new map, timelineBleMap, which holds the mapping of timeline entry IDs to vehicle identities. --- www/js/diary/LabelTab.tsx | 23 ++++++++++- www/js/diary/timelineHelper.ts | 18 ++++++++ www/js/survey/inputMatcher.ts | 75 +++++++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 0ceaf0505..1ed20c0cc 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -21,13 +21,19 @@ import { updateLocalUnprocessedInputs, unprocessedLabels, unprocessedNotes, + updateUnprocessedBleScans, + unprocessedBleScans, } from './timelineHelper'; import { fillLocationNamesOfTrip, resetNominatimLimiter } from './addressNamesHelper'; import { getLabelOptions, labelOptionByValue } from '../survey/multilabel/confirmHelper'; import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import { useTheme } from 'react-native-paper'; import { getPipelineRangeTs } from '../services/commHelper'; -import { getNotDeletedCandidates, mapInputsToTimelineEntries } from '../survey/inputMatcher'; +import { + getNotDeletedCandidates, + mapBleScansToTimelineEntries, + mapInputsToTimelineEntries, +} from '../survey/inputMatcher'; import { configuredFilters as multilabelConfiguredFilters } from '../survey/multilabel/infinite_scroll_filters'; import { configuredFilters as enketoConfiguredFilters } from '../survey/enketo/infinite_scroll_filters'; import LabelTabContext, { @@ -57,6 +63,7 @@ const LabelTab = () => { const [timelineMap, setTimelineMap] = useState(null); const [timelineLabelMap, setTimelineLabelMap] = useState(null); const [timelineNotesMap, setTimelineNotesMap] = useState(null); + const [timelineBleMap, setTimelineBleMap] = useState(null); const [displayedEntries, setDisplayedEntries] = useState(null); const [refreshTime, setRefreshTime] = useState(null); const [isLoading, setIsLoading] = useState('replace'); @@ -102,6 +109,11 @@ const LabelTab = () => { setTimelineLabelMap(newTimelineLabelMap); setTimelineNotesMap(newTimelineNotesMap); + if (appConfig.vehicle_identities?.length) { + const newTimelineBleMap = mapBleScansToTimelineEntries(allEntries, appConfig); + setTimelineBleMap(newTimelineBleMap); + } + applyFilters(timelineMap, newTimelineLabelMap); } catch (e) { displayError(e, t('errors.while-updating-timeline')); @@ -157,6 +169,15 @@ const LabelTab = () => { logDebug(`LabelTab: After updating unprocessedInputs, unprocessedLabels = ${JSON.stringify(unprocessedLabels)}; unprocessedNotes = ${JSON.stringify(unprocessedNotes)}`); + if (appConfig.vehicle_identities?.length) { + await updateUnprocessedBleScans({ + start_ts: pipelineRange.start_ts, + end_ts: Date.now() / 1000, + }); + logDebug(`LabelTab: After updating unprocessedBleScans, + unprocessedBleScans = ${JSON.stringify(unprocessedBleScans)}; + `); + } setPipelineRange(pipelineRange); } catch (e) { displayError(e, t('errors.while-loading-pipeline-range')); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index f140f1750..f64318731 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -14,6 +14,7 @@ import { TimestampRange, CompositeTrip, UnprocessedTrip, + BluetoothBleData, } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; import { LabelOptions } from '../types/labelTypes'; @@ -175,6 +176,23 @@ export async function updateAllUnprocessedInputs( await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } +export let unprocessedBleScans: BEMData[] = []; + +export async function updateUnprocessedBleScans(queryRange: TimestampRange) { + const tq = { + key: 'write_ts', + startTs: queryRange.start_ts, + endTs: queryRange.end_ts, + }; + const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; + getUnifiedDataForInterval('background/background_ble', tq, getMethod).then( + (bleScans: BEMData[]) => { + logDebug(`Read ${bleScans.length} BLE scans`); + unprocessedBleScans = bleScans; + }, + ); +} + export function keysForLabelInputs(appConfig: AppConfig) { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { return ['manual/trip_user_input']; diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index da802d2e8..fdfb25579 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,7 +1,18 @@ import { logDebug, displayErrorMsg } from '../plugin/logger'; import { DateTime } from 'luxon'; -import { CompositeTrip, ConfirmedPlace, TimelineEntry, UserInputEntry } from '../types/diaryTypes'; -import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from '../diary/timelineHelper'; +import { + BluetoothBleData, + CompositeTrip, + ConfirmedPlace, + TimelineEntry, + UserInputEntry, +} from '../types/diaryTypes'; +import { + keysForLabelInputs, + unprocessedBleScans, + unprocessedLabels, + unprocessedNotes, +} from '../diary/timelineHelper'; import { getLabelInputDetails, inputType2retKey, @@ -11,6 +22,7 @@ import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext'; import { MultilabelKey } from '../types/labelTypes'; import { EnketoUserInputEntry } from './enketo/enketoHelper'; import { AppConfig } from '../types/appConfigTypes'; +import { BEMData } from '../types/serverData'; const EPOCH_MAXIMUM = 2 ** 31 - 1; @@ -340,3 +352,62 @@ export function mapInputsToTimelineEntries( return [timelineLabelMap, timelineNotesMap]; } + +function validBleScanForTimelineEntry(tlEntry: TimelineEntry, bleScan: BEMData) { + let entryStart = (tlEntry as CompositeTrip).start_ts || (tlEntry as ConfirmedPlace).enter_ts; + let entryEnd = (tlEntry as CompositeTrip).end_ts || (tlEntry as ConfirmedPlace).exit_ts; + + if (!entryStart && entryEnd) { + /* if a place has no enter time, this is the first start_place of the first composite trip object + so we will set the start time to the start of the day of the end time for the purpose of comparison */ + entryStart = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger(); + } + + if (!entryEnd) { + /* if a place has no exit time, the user hasn't left there yet + so we will set the end time as high as possible for the purpose of comparison */ + entryEnd = EPOCH_MAXIMUM; + } + + return bleScan.data.ts >= entryStart && bleScan.data.ts <= entryEnd; +} + +function getBleScansForTimelineEntry( + tlEntry: TimelineEntry, + bleScans: BEMData[], +) { + return bleScans.filter((scan) => validBleScanForTimelineEntry(tlEntry, scan)); +} + +export function mapBleScansToTimelineEntries(allEntries: TimelineEntry[], appConfig: AppConfig) { + const timelineBleMap = {}; + for (const tlEntry of allEntries) { + const matches = getBleScansForTimelineEntry(tlEntry, unprocessedBleScans); + if (!matches.length) { + continue; + } + + // count the number of occurrences of each major:minor pair + const majorMinorCounts = {}; + matches.forEach((match) => { + const majorMinor = match.data.major + ':' + match.data.minor; + majorMinorCounts[majorMinor] = majorMinorCounts[majorMinor] + ? majorMinorCounts[majorMinor] + 1 + : 1; + }); + // determine the major:minor pair with the highest count + const match = Object.keys(majorMinorCounts).reduce((a, b) => + majorMinorCounts[a] > majorMinorCounts[b] ? a : b, + ); + // find the vehicle identity that uses this major:minor pair + const vehicleIdentity = appConfig.vehicle_identities?.find((vi) => + vi.bluetooth_major_minor.includes(match), + ); + if (vehicleIdentity) { + timelineBleMap[tlEntry._id.$oid] = vehicleIdentity; + } else { + displayErrorMsg(`No vehicle identity found for major:minor pair ${match}`); + } + } + return timelineBleMap; +} From bff97ae35d983805572c4270f1e4e954e49d2ceb Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 12 Apr 2024 15:44:29 -0400 Subject: [PATCH 11/20] use labels OR bluetooth vehicle identity to determine "confirmed mode" With the ability to detect vehicles by Bluetooth, we now have 2 ways to determine the mode of a trip: user labels or Bluetooth. This makes it necessary to refactor and make the code flexible to handle both. "Confirmed mode" is the new term for a mode that has been determined; either by the user-provided MODE label, or by the vehicle identity determined by Bluetooth. "Confirmed mode" is given by the "confirmedModeFor" function. Throughout the codebase, we now use `confirmedModeFor(trip)` instead of `labelFor(trip, 'MODE')`. For existing configurations that use user MODE labels, this does not change the behavior. -- While doing this I refactored useGeojsonForTrip to directly accept the base mode, since that is what it really needs, instead of the labeledMode. As a result, it also doesn't need labelOptions as an argument anymore. --- www/__tests__/timelineHelper.test.ts | 7 ++-- www/js/diary/LabelTab.tsx | 9 ++++++ www/js/diary/LabelTabContext.ts | 2 ++ www/js/diary/cards/ModesIndicator.tsx | 12 +++---- www/js/diary/cards/TripCard.tsx | 4 +-- www/js/diary/details/LabelDetailsScreen.tsx | 32 +++++++++---------- .../details/TripSectionsDescriptives.tsx | 16 +++++----- www/js/diary/timelineHelper.ts | 11 ++----- 8 files changed, 48 insertions(+), 45 deletions(-) diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index bd817f084..aafe13926 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -28,7 +28,7 @@ afterAll(() => { describe('useGeojsonForTrip', () => { it('work with an empty input', () => { - const testVal = useGeojsonForTrip({} as any, {} as any); + const testVal = useGeojsonForTrip({} as any); expect(testVal).toBeFalsy; }); @@ -43,10 +43,7 @@ describe('useGeojsonForTrip', () => { }; it('works without labelMode flag', () => { - const testValue = useGeojsonForTrip( - mockTLH.mockCompDataTwo.phone_data[1].data, - mockTLH.mockLabelOptions, - ) as GeoJSONData; + const testValue = useGeojsonForTrip(mockTLH.mockCompDataTwo.phone_data[1].data) as GeoJSONData; expect(testValue).toBeTruthy; checkGeojson(testValue); expect(testValue.data.features.length).toBe(3); diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 1ed20c0cc..938a861f3 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -331,6 +331,14 @@ const LabelTab = () => { return chosenLabel ? labelOptionByValue(chosenLabel, labelType) : undefined; }; + /** + * @param tlEntry The trip or place object to get the confirmed mode for + * @returns Confirmed mode, which could be a vehicle identity as determined by Bluetooth scans, + * or the label option from a user-given 'MODE' label, or undefined if neither exists. + */ + const confirmedModeFor = (tlEntry: TimelineEntry) => + timelineBleMap?.[tlEntry._id.$oid] || labelFor(tlEntry, 'MODE'); + function addUserInputToEntry(oid: string, userInput: any, inputType: 'label' | 'note') { const tlEntry = timelineMap?.get(oid); if (!pipelineRange || !tlEntry) @@ -372,6 +380,7 @@ const LabelTab = () => { userInputFor, labelFor, notesFor, + confirmedModeFor, addUserInputToEntry, displayedEntries, filterInputs, diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts index 9e80cccae..791cb4cd5 100644 --- a/www/js/diary/LabelTabContext.ts +++ b/www/js/diary/LabelTabContext.ts @@ -2,6 +2,7 @@ import { createContext } from 'react'; import { TimelineEntry, TimestampRange, UserInputEntry } from '../types/diaryTypes'; import { LabelOption, LabelOptions, MultilabelKey } from '../types/labelTypes'; import { EnketoUserInputEntry } from '../survey/enketo/enketoHelper'; +import { VehicleIdentity } from '../types/appConfigTypes'; export type UserInputMap = { /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input @@ -34,6 +35,7 @@ type ContextProps = { userInputFor: (tlEntry: TimelineEntry) => UserInputMap | undefined; notesFor: (tlEntry: TimelineEntry) => UserInputEntry[] | undefined; labelFor: (tlEntry: TimelineEntry, labelType: MultilabelKey) => LabelOption | undefined; + confirmedModeFor: (tlEntry: TimelineEntry) => VehicleIdentity | LabelOption | undefined; addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void; displayedEntries: TimelineEntry[] | null; filterInputs: LabelTabFilter[]; diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index bba65c107..4e68da4de 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -3,22 +3,22 @@ import { View, StyleSheet } from 'react-native'; import color from 'color'; import LabelTabContext from '../LabelTabContext'; import { logDebug } from '../../plugin/logger'; -import { getBaseModeByValue } from '../diaryHelper'; +import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; import { Text, Icon, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; const ModesIndicator = ({ trip, detectedModes }) => { const { t } = useTranslation(); - const { labelOptions, labelFor } = useContext(LabelTabContext); + const { labelOptions, labelFor, confirmedModeFor } = useContext(LabelTabContext); const { colors } = useTheme(); const indicatorBackgroundColor = color(colors.onPrimary).alpha(0.8).rgb().string(); let indicatorBorderColor = color('black').alpha(0.5).rgb().string(); let modeViews; - const labeledModeForTrip = labelFor(trip, 'MODE'); - if (labelOptions && labeledModeForTrip?.value) { - const baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions); + const confirmedModeForTrip = confirmedModeFor(trip); + if (labelOptions && confirmedModeForTrip?.value) { + const baseMode = getBaseModeByKey(confirmedModeForTrip.baseMode); indicatorBorderColor = baseMode.color; logDebug(`TripCard: got baseMode = ${JSON.stringify(baseMode)}`); modeViews = ( @@ -32,7 +32,7 @@ const ModesIndicator = ({ trip, detectedModes }) => { fontWeight: '500', textDecorationLine: 'underline', }}> - {labelFor(trip, 'MODE')?.text} + {confirmedModeForTrip.text} ); diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 3504bde16..f0f8a1284 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -42,9 +42,9 @@ const TripCard = ({ trip, isFirstInList }: Props) => { } = useDerivedProperties(trip); let [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); const navigation = useNavigation(); - const { labelOptions, labelFor, notesFor } = useContext(LabelTabContext); + const { labelOptions, confirmedModeFor, notesFor } = useContext(LabelTabContext); const tripGeojson = - trip && labelOptions && useGeojsonForTrip(trip, labelOptions, labelFor(trip, 'MODE')?.value); + trip && labelOptions && useGeojsonForTrip(trip, confirmedModeFor(trip)?.baseMode); const isDraft = trip.key.includes('UNPROCESSED'); const flavoredTheme = getTheme(isDraft ? 'draft' : undefined); diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index 4985300cb..9627ebcaa 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -31,7 +31,7 @@ import { CompositeTrip } from '../../types/diaryTypes'; import NavBar from '../../components/NavBar'; const LabelScreenDetails = ({ route, navigation }) => { - const { timelineMap, labelOptions, labelFor } = useContext(LabelTabContext); + const { timelineMap, labelOptions, confirmedModeFor } = useContext(LabelTabContext); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); const appConfig = useAppConfig(); @@ -41,17 +41,16 @@ const LabelScreenDetails = ({ route, navigation }) => { const { displayDate, displayStartTime, displayEndTime } = useDerivedProperties(trip); const [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); - const [modesShown, setModesShown] = useState<'labeled' | 'detected'>(() => + const [modesShown, setModesShown] = useState<'confirmed' | 'detected'>(() => // if trip has a labeled mode, initial state shows that; otherwise, show detected modes - trip && labelFor(trip, 'MODE')?.value ? 'labeled' : 'detected', + trip && confirmedModeFor(trip)?.value ? 'confirmed' : 'detected', ); const tripGeojson = trip && labelOptions && useGeojsonForTrip( trip, - labelOptions, - modesShown == 'labeled' ? labelFor(trip, 'MODE')?.value : undefined, + modesShown == 'confirmed' ? confirmedModeFor(trip)?.baseMode : undefined, ); const mapOpts = { minZoom: 3, maxZoom: 17 }; @@ -86,23 +85,24 @@ const LabelScreenDetails = ({ route, navigation }) => { )} - - {/* Full-size Leaflet map, with zoom controls */} - + {tripGeojson && ( + // Full-size Leaflet map, with zoom controls + + )} {/* If trip is labeled, show a toggle to switch between "Labeled Mode" and "Detected Modes" otherwise, just show "Detected" */} - {trip && labelFor(trip, 'MODE')?.value ? ( + {trip && confirmedModeFor(trip)?.value ? ( setModesShown(v)} + onValueChange={(v: 'confirmed' | 'detected') => setModesShown(v)} value={modesShown} density="medium" buttons={[ - { label: t('diary.labeled-mode'), value: 'labeled' }, + { label: t('diary.labeled-mode'), value: 'confirmed' }, { label: t('diary.detected-modes'), value: 'detected' }, ]} /> @@ -118,7 +118,7 @@ const LabelScreenDetails = ({ route, navigation }) => { )} {/* section-by-section breakdown of duration, distance, and mode */} - + {/* Overall trip duration, distance, and modes. Only show this when multiple sections are shown, and we are showing detected modes. If we just showed the labeled mode or a single section, this would be redundant. */} diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index fdab61eb3..9e117021c 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -5,8 +5,8 @@ import useDerivedProperties from '../useDerivedProperties'; import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; import LabelTabContext from '../LabelTabContext'; -const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { - const { labelOptions, labelFor } = useContext(LabelTabContext); +const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => { + const { labelOptions, labelFor, confirmedModeFor } = useContext(LabelTabContext); const { displayStartTime, displayTime, @@ -17,14 +17,14 @@ const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { const { colors } = useTheme(); - const labeledModeForTrip = labelFor(trip, 'MODE'); + const confirmedModeForTrip = confirmedModeFor(trip); let sections = formattedSectionProperties; /* if we're only showing the labeled mode, or there are no sections (i.e. unprocessed trip), we treat this as unimodal and use trip-level attributes to construct a single section */ - if ((showLabeledMode && labeledModeForTrip) || !trip.sections?.length) { + if ((showConfirmedMode && confirmedModeForTrip) || !trip.sections?.length) { let baseMode; - if (showLabeledMode && labelOptions && labeledModeForTrip) { - baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions); + if (showConfirmedMode && labelOptions && confirmedModeForTrip) { + baseMode = getBaseModeByKey(confirmedModeForTrip.baseMode); } else { baseMode = getBaseModeByKey('UNPROCESSED'); } @@ -62,9 +62,9 @@ const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { - {showLabeledMode && labeledModeForTrip && ( + {showConfirmedMode && confirmedModeForTrip && ( - {labeledModeForTrip.text} + {confirmedModeForTrip.text} )} diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index f64318731..b0b65b2c8 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -27,19 +27,14 @@ const cachedGeojsons: Map = new Map(); /** * @description Gets a formatted GeoJSON object for a trip, including the start and end places and the trajectory. */ -export function useGeojsonForTrip( - trip: CompositeTrip, - labelOptions: LabelOptions, - labeledMode?: string, -) { +export function useGeojsonForTrip(trip: CompositeTrip, baseMode?: string) { if (!trip?._id?.$oid) return; - const gjKey = `trip-${trip._id.$oid}-${labeledMode || 'detected'}`; + const gjKey = `trip-${trip._id.$oid}-${baseMode || 'detected'}`; if (cachedGeojsons.has(gjKey)) { return cachedGeojsons.get(gjKey); } - const trajectoryColor = - (labeledMode && getBaseModeByValue(labeledMode, labelOptions)?.color) || undefined; + const trajectoryColor = (baseMode && getBaseModeByKey(baseMode)?.color) || undefined; logDebug("Reading trip's " + trip.locations.length + ' location points at ' + new Date()); const features = [ From 463c2a5b642087e3901c995f8a57d4287d802067 Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Sat, 13 Apr 2024 14:20:29 -0700 Subject: [PATCH 12/20] =?UTF-8?q?=F0=9F=A4=A1=20Switch=20the=20simulation?= =?UTF-8?q?=20code=20in=20the=20UI=20to=20use=20the=20mock=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In https://github.com/e-mission/e-mission-data-collection/pull/221 we have added a new method to the data collection interface that allows us to create mock BLE objects and save them using the native methods. This allows us to simulate the real world scenario more closely, by saving from the native code. while still avoiding hacks to real code. ---------------------------------- Android: ``` 04-13 13:34:28.204 13563 13563 I chromium: "state": "CLRegionStateInside" 04-13 13:34:28.204 13563 13563 I chromium: }", source: https://localhost/plugins/cordova-plugin-em-unifiedlogger/www/unifiedlogger.js (49) 04-13 13:34:28.222 13563 13763 I System.out: About to execute query SELECT data FROM userCache WHERE key = 'background/bluetooth_ble' AND type = 'sensor-data' AND write_ts >= 1.713040168E9 AND write_ts <= 1.713040468E9 ORDER BY write_ts DESC 04-13 13:34:28.241 13563 13763 W PluginManager: THREAD WARNING: exec() call to UserCache.getSensorDataForInterval blocked the main thread for 20ms. Plugin should use CordovaInterface.getThreadPool(). 04-13 13:34:28.253 13563 31932 D BuiltinUserCache: Added value for key background/bluetooth_ble at time 1.713040468221E9 04-13 13:34:28.273 13563 31932 D BuiltinUserCache: Added value for key background/bluetooth_ble at time 1.713040468254E9 04-13 13:34:28.319 13563 31932 D BuiltinUserCache: Added value for key background/bluetooth_ble at time 1.713040468276E9 04-13 13:34:28.351 13563 31932 D BuiltinUserCache: Added value for key background/bluetooth_ble at time 1.71304046832E9 04-13 13:34:28.371 13563 13563 I TripDiaryStateMachineRcvr: noarg constructor called 04-13 13:34:28.373 13563 31932 D BuiltinUserCache: Added value for key background/bluetooth_ble at time 1.713040468352E9 ``` iOS ``` 2024-04-13 13:38:22.029076-0700 emission[77938:10285250] DEBUG:[BLE] didRangeBeaconsInRegion 2024-04-13 13:38:22.029320-0700 emission[77938:10285250] DEBUG: [BLE] didRangeBeaconsInRegion 2024-04-13 13:38:22.031065-0700 emission[77938:10285250] DEBUG:{ } 2024-04-13 13:38:25.060123-0700 emission[77938:10285250] data has 174 bytes, str has size 174 2024-04-13 13:38:25.062087-0700 emission[77938:10285250] data has 174 bytes, str has size 174 2024-04-13 13:38:25.063593-0700 emission[77938:10285250] data has 173 bytes, str has size 173 2024-04-13 13:38:25.065085-0700 emission[77938:10285250] data has 175 bytes, str has size 175 2024-04-13 13:38:25.066255-0700 emission[77938:10285250] data has 175 bytes, str has size 175 2024-04-13 13:38:26.343114-0700 emission[77938:10285250] THREAD WARNING: ['DataCollection'] took '4307.931885' ms. Plugin should use a background thread. 2024-04-13 13:38:26.350811-0700 emission[77938:10285250] In TripDiaryStateMachine, received transition T_BLE_BEACON_FOUND in state STATE_ONGOING_TRIP 2024-04-13 13:38:26.350982-0700 emission[77938:10285250] DEBUG: In TripDiaryStateMachine, received transition T_BLE_BEACON_FOUND in state STATE_ONGOING_TRIP 2024-04-13 13:38:26.352531-0700 emission[77938:10285250] data has 92 bytes, str has size 92 2024-04-13 13:38:26.354445-0700 emission[77938:10285250] data has 69 bytes, str has size 69 2024-04-13 13:38:26.355964-0700 emission[77938:10285250] Got unexpected transition T_BLE_BEACON_FOUND in state STATE_ONGOING_TRIP, ignoring 2024-04-13 13:38:26.356106-0700 emission[77938:10285250] Ignoring silent push notification ``` --- www/js/bluetooth/BluetoothScanPage.tsx | 49 +++++++++++++------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/www/js/bluetooth/BluetoothScanPage.tsx b/www/js/bluetooth/BluetoothScanPage.tsx index d855c47c3..a4e5bda9e 100644 --- a/www/js/bluetooth/BluetoothScanPage.tsx +++ b/www/js/bluetooth/BluetoothScanPage.tsx @@ -97,15 +97,14 @@ const BluetoothScanPage = ({ ...props }: any) => { in_range: status, }, })); - // using putMessage instead of putSensorData as a temporary hack for now - // since putSensorData is not exposed through javascript let { monitorResult: _, in_range: _, ...noResultDevice } = sampleBLEDevices[uuid]; - window['cordova']?.plugins?.BEMUserCache.putMessage('background/bluetooth_ble', { - eventType: status ? 'REGION_ENTER' : 'REGION_EXIT', - ts: Date.now() / 1000, // our convention is to have timestamps in seconds - uuid: uuid, - ...noResultDevice, // gives us uuid, major, minor - }); + window['cordova']?.plugins?.BEMDataCollection.mockBLEObjects( + status ? 'REGION_ENTER' : 'REGION_EXIT', + uuid, + undefined, + undefined, + 1, + ); if (!status) { forceTransition('BLE_BEACON_LOST'); } @@ -131,13 +130,13 @@ const BluetoothScanPage = ({ ...props }: any) => { } = sampleBLEDevices[uuid]; let parsedResult = JSON.parse(result); parsedResult.beacons.forEach((beacon) => { - window['cordova']?.plugins?.BEMUserCache.putMessage('background/bluetooth_ble', { - eventType: 'RANGE_UPDATE', - ts: Date.now() / 1000, // our convention is to have timestamps in seconds - uuid: uuid, - ...noResultDevice, // gives us uuid, major, minor - ...beacon, // gives us proximity, accuracy and rssi - }); + window['cordova']?.plugins?.BEMDataCollection.mockBLEObjects( + 'RANGE_UPDATE', + uuid, + beacon.major, + beacon.minor, + 5, + ); }); // we only check for the transition on "real" callbacks to avoid excessive // spurious callbacks on android @@ -147,17 +146,17 @@ const BluetoothScanPage = ({ ...props }: any) => { // (last param) let nowSec = DateTime.now().toUnixInteger(); let tq = { key: 'write_ts', startTs: nowSec - 5 * 60, endTs: nowSec }; - let readBLEReadingsPromise = window['cordova']?.plugins?.BEMUserCache.getMessagesForInterval( - 'background/bluetooth_ble', - tq, - false, - ); + let readBLEReadingsPromise = window[ + 'cordova' + ]?.plugins?.BEMUserCache.getSensorDataForInterval('background/bluetooth_ble', tq, false); readBLEReadingsPromise.then((bleResponses) => { - let lastThreeResponses = bleResponses.slice(0, 3); - if (!lastThreeResponses.every((x) => x.eventType == 'RANGE_UPDATE')) { + // we add 5 entries at a time, so if we want 3 button presses, + // we really want 15 entries + let lastFifteenResponses = bleResponses.slice(0, 15); + if (!lastFifteenResponses.every((x) => x.eventType == 'RANGE_UPDATE')) { console.log( 'Last three entries ' + - lastThreeResponses.map((x) => x.eventType) + + lastFifteenResponses.map((x) => x.eventType) + ' are not all RANGE_UPDATE, skipping transition', ); return; @@ -168,7 +167,9 @@ const BluetoothScanPage = ({ ...props }: any) => { } } - async function simulateLocation(state: String) {} + async function simulateLocation(state: String) { + forceTransition(state); + } // BLE LOGIC async function startBeaconScanning() { From fbedee41cc0f6933980edf50361c949f6c5dbd9c Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 14 Apr 2024 00:22:30 -0400 Subject: [PATCH 13/20] during bluetooth_ble matching, convert major and minor to hexadecimal The background/bluetooth_ble data type has "major" and "minor" as decimal integers. (https://github.com/e-mission/e-mission-docs/issues/1062#issuecomment-2052278631) But in the vehicle_identities spec, we have major:minor pairs as hexadecimal strings. So we will need to convert. decimalToHex handles this and allows us to add padding, ensuring the converted major and minor are always 4 hex characters. (so 1 becomes "0001", 255 becomes "00ff", etc.) --- www/js/survey/inputMatcher.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index fdfb25579..a02f8ff76 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -379,6 +379,19 @@ function getBleScansForTimelineEntry( return bleScans.filter((scan) => validBleScanForTimelineEntry(tlEntry, scan)); } +/** + * @description Convert a decimal number to a hexadecimal string, with optional padding + * @example decimalToHex(245) => 'f5' + * @example decimalToHex(245, 4) => '00f5' + */ +function decimalToHex(d: string | number, padding?: number) { + let hex = Number(d).toString(16); + while (hex.length < (padding || 0)) { + hex = '0' + hex; + } + return hex; +} + export function mapBleScansToTimelineEntries(allEntries: TimelineEntry[], appConfig: AppConfig) { const timelineBleMap = {}; for (const tlEntry of allEntries) { @@ -390,7 +403,9 @@ export function mapBleScansToTimelineEntries(allEntries: TimelineEntry[], appCon // count the number of occurrences of each major:minor pair const majorMinorCounts = {}; matches.forEach((match) => { - const majorMinor = match.data.major + ':' + match.data.minor; + const major = decimalToHex(match.data.major, 4); + const minor = decimalToHex(match.data.minor, 4); + const majorMinor = major + ':' + minor; majorMinorCounts[majorMinor] = majorMinorCounts[majorMinor] ? majorMinorCounts[majorMinor] + 1 : 1; From 3fd28632b2907ce5b7ff62c27480e631a84f0e92 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 14 Apr 2024 17:53:31 -0400 Subject: [PATCH 14/20] add "Refresh App Configuration" row to Profile tab This feature allows the user to refresh the app configuration without having to log out and back in. (This will be handy for our alpha "run-through" of the fermata project, https://github.com/e-mission/nrel-openpath-deploy-configs/pull/89. we will likely need to update beacon + vehicle info periodically. dynamicConfig.ts -- in fetchConfig, pass an option to the fetch API so it does not use a cached copy of the config. We want to make sure it's actually checking for the latest config. -- export function loadNewConfig so it can be used in ProfileSettings.tsx ProfileSettings.tsx --add function refreshConfig, which calls loadNewConfig and triggers a hard refresh if the config has changed. Toast messages to guide the user through the process. --add the new row itself: "Refresh App Configuration" (which also shows the current version of the app config) appConfigTypes.ts --add prop 'version' to config type. Every config has this property. Testing done: On a local dev environment with locally hosted configs, I was signed into an nrel-commute opcode. I updated the local config file, changing "version" from 1 to 2 and changing "use_imperial" from true to false. In the UI Profile tab, the new row showed "Current version: 1". After clicking the row, the app reloads and UI now shows 'km' instead of 'miles'. I went back to the Profile tab and the new row now shows "Current version: 2". Clicking the row a second time triggers a toast message saying "Already up to date!" --- www/i18n/en.json | 6 +++++- www/js/config/dynamicConfig.ts | 9 +++++---- www/js/control/ProfileSettings.tsx | 17 ++++++++++++++++- www/js/types/appConfigTypes.ts | 1 + 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index ffbfb8ea8..9a8b6bb61 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -48,7 +48,11 @@ "reminders-time-of-day": "Time of Day for Reminders ({{time}})", "upcoming-notifications": "Upcoming Notifications", "dummy-notification": "Dummy Notification in 5 Seconds", - "log-out": "Log Out" + "log-out": "Log Out", + "refresh-app-config": "Refresh App Configuration", + "current-version": "Current version: {{version}}", + "refreshing-app-config": "Refreshing app configuration, please wait...", + "already-up-to-date": "Already up to date!" }, "general-settings": { diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 9773a1ead..62e0186fb 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -134,16 +134,17 @@ async function readConfigFromServer(studyLabel: string) { */ async function fetchConfig(studyLabel: string, alreadyTriedLocal?: boolean) { logDebug('Received request to join ' + studyLabel); - const downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${studyLabel}.nrel-op.json`; + let downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${studyLabel}.nrel-op.json`; if (!__DEV__ || alreadyTriedLocal) { logDebug('Fetching config from github'); - const r = await fetch(downloadURL); + const r = await fetch(downloadURL, { cache: 'reload' }); if (!r.ok) throw new Error('Unable to fetch config from github'); return r.json(); // TODO: validate, make sure it has required fields } else { logDebug('Running in dev environment, checking for locally hosted config'); try { - const r = await fetch('http://localhost:9090/configs/' + studyLabel + '.nrel-op.json'); + downloadURL = `http://localhost:9090/configs/${studyLabel}.nrel-op.json`; + const r = await fetch(downloadURL, { cache: 'reload' }); if (!r.ok) throw new Error('Local config not found'); return r.json(); } catch (err) { @@ -227,7 +228,7 @@ function extractSubgroup(token: string, config: AppConfig): string | undefined { * @param existingVersion If the new config's version is the same, we won't update * @returns boolean representing whether the config was updated or not */ -function loadNewConfig(newToken: string, existingVersion?: number): Promise { +export function loadNewConfig(newToken: string, existingVersion?: number): Promise { const newStudyLabel = extractStudyName(newToken); return readConfigFromServer(newStudyLabel) .then((downloadedConfig) => { diff --git a/www/js/control/ProfileSettings.tsx b/www/js/control/ProfileSettings.tsx index f851a6651..ab381e594 100644 --- a/www/js/control/ProfileSettings.tsx +++ b/www/js/control/ProfileSettings.tsx @@ -26,7 +26,7 @@ import ControlCollectionHelper, { helperToggleLowAccuracy, forceTransition, } from './ControlCollectionHelper'; -import { resetDataAndRefresh } from '../config/dynamicConfig'; +import { loadNewConfig, resetDataAndRefresh } from '../config/dynamicConfig'; import { AppContext } from '../App'; import { shareQR } from '../components/QrCode'; import { storageClear } from '../plugin/storage'; @@ -307,6 +307,16 @@ const ProfileSettings = () => { }, 1500); } + async function refreshConfig() { + AlertManager.addMessage({ text: t('control.refreshing-app-config') }); + const updated = await loadNewConfig(authSettings.opcode, appConfig?.version); + if (updated) { + window.location.reload(); + } else { + AlertManager.addMessage({ text: t('control.already-up-to-date') }); + } + } + //Platform.OS returns "web" now, but could be used once it's fully a Native app //for now, use window.cordova.platformId @@ -434,6 +444,11 @@ const ProfileSettings = () => { textKey="control.email-log" iconName="email" action={() => sendEmail('loggerDB')}> + Date: Sun, 14 Apr 2024 19:09:48 -0400 Subject: [PATCH 15/20] allow android emulator to download locally hosted configs by using 10.0.2.2 instead of localhost --- www/js/config/dynamicConfig.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 62e0186fb..d9b9f3235 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -143,7 +143,11 @@ async function fetchConfig(studyLabel: string, alreadyTriedLocal?: boolean) { } else { logDebug('Running in dev environment, checking for locally hosted config'); try { - downloadURL = `http://localhost:9090/configs/${studyLabel}.nrel-op.json`; + if (window['cordova'].platformId == 'android') { + downloadURL = `http://10.0.2.2:9090/configs/${studyLabel}.nrel-op.json`; + } else { + downloadURL = `http://localhost:9090/configs/${studyLabel}.nrel-op.json`; + } const r = await fetch(downloadURL, { cache: 'reload' }); if (!r.ok) throw new Error('Local config not found'); return r.json(); From 933f9a4c2b76b419973b61f76d55f10b227f6086 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 14 Apr 2024 23:52:40 -0400 Subject: [PATCH 16/20] CSP allow 10.0.2.2 for android emulator --- www/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/index.html b/www/index.html index 696343b7a..6957a38d6 100644 --- a/www/index.html +++ b/www/index.html @@ -3,7 +3,7 @@ - + From fcaec5c88bd03ce8b99455c4b456a90658fa7d56 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 15 Apr 2024 00:29:04 -0400 Subject: [PATCH 17/20] fix typo: "background_ble" -> "bluetooth_ble" This would explain why I was never getting any entries returned from the unified loader --- www/js/diary/timelineHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 7e874a595..d82adb4fb 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -182,7 +182,7 @@ export async function updateUnprocessedBleScans(queryRange: TimestampRange) { endTs: queryRange.end_ts, }; const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; - getUnifiedDataForInterval('background/background_ble', tq, getMethod).then( + getUnifiedDataForInterval('background/bluetooth_ble', tq, getMethod).then( (bleScans: BEMData[]) => { logDebug(`Read ${bleScans.length} BLE scans`); unprocessedBleScans = bleScans; From b2d22b769cc3c4d8dbbbe6e7524761134f263c21 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 15 Apr 2024 13:13:53 -0400 Subject: [PATCH 18/20] revert "CSP allow 10.0.2.2 for android emulator" https://github.com/e-mission/e-mission-phone/pull/1145#discussion_r1565985396 --- www/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/index.html b/www/index.html index 6957a38d6..696343b7a 100644 --- a/www/index.html +++ b/www/index.html @@ -3,7 +3,7 @@ - + From 0b6b844d3942de7f6b2aeb6ea01cb262a0d78e24 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 15 Apr 2024 15:04:25 -0400 Subject: [PATCH 19/20] add "confirmedMode" to derived properties & use for conditional surveys For the fermata project, the survey shown for a trip should depend on what vehicle was detected from bluetooth. For unprocessed trips, this information is not going to be in the raw trip object. That's why we have "timelineBleMap", which is kept separate from the raw trip objects. But to access this while evaluating the conditional survey "showsIf" expressions, we'll need to include more things in the eval's scope. I decided that it would be appropriate to use our "derived properties" hook for this. we can add a new property to derived properties, then we can include all the derived properties in the scope in which the "showsIf" expressions are evaluated. This will probably be adjusted later. Note: I also simplified the type definition of DerivedProperties. It now just uses the return type of the useDerivedProperties hook. --- www/js/diary/useDerivedProperties.tsx | 7 +++++-- www/js/survey/enketo/UserInputButton.tsx | 4 +++- www/js/survey/enketo/conditionalSurveys.ts | 4 +++- www/js/types/diaryTypes.ts | 14 ++------------ 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx index fe324ee3f..a6985a8e5 100644 --- a/www/js/diary/useDerivedProperties.tsx +++ b/www/js/diary/useDerivedProperties.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; import { useImperialConfig } from '../config/useImperialConfig'; import { getFormattedDate, @@ -9,9 +9,11 @@ import { getDetectedModes, isMultiDay, } from './diaryHelper'; +import LabelTabContext from './LabelTabContext'; const useDerivedProperties = (tlEntry) => { const imperialConfig = useImperialConfig(); + const { confirmedModeFor } = useContext(LabelTabContext); return useMemo(() => { const beginFmt = tlEntry.start_fmt_time || tlEntry.enter_fmt_time; @@ -21,6 +23,7 @@ const useDerivedProperties = (tlEntry) => { const tlEntryIsMultiDay = isMultiDay(beginFmt, endFmt); return { + confirmedMode: confirmedModeFor(tlEntry), displayDate: getFormattedDate(beginFmt, endFmt), displayStartTime: getLocalTimeString(beginDt), displayEndTime: getLocalTimeString(endDt), @@ -32,7 +35,7 @@ const useDerivedProperties = (tlEntry) => { distanceSuffix: imperialConfig.distanceSuffix, detectedModes: getDetectedModes(tlEntry), }; - }, [tlEntry, imperialConfig]); + }, [tlEntry, imperialConfig, confirmedModeFor(tlEntry)]); }; export default useDerivedProperties; diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index 5817e7ed3..118b687f8 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -17,6 +17,7 @@ import EnketoModal from './EnketoModal'; import LabelTabContext from '../../diary/LabelTabContext'; import useAppConfig from '../../useAppConfig'; import { getSurveyForTimelineEntry } from './conditionalSurveys'; +import useDerivedProperties from '../../diary/useDerivedProperties'; type Props = { timelineEntry: any; @@ -29,6 +30,7 @@ const UserInputButton = ({ timelineEntry }: Props) => { const [prevSurveyResponse, setPrevSurveyResponse] = useState(undefined); const [modalVisible, setModalVisible] = useState(false); const { userInputFor, addUserInputToEntry } = useContext(LabelTabContext); + const derivedTripProps = useDerivedProperties(timelineEntry); // which survey will this button launch? const [surveyName, notFilledInLabel] = useMemo(() => { @@ -38,7 +40,7 @@ const UserInputButton = ({ timelineEntry }: Props) => { return ['TripConfirmSurvey', t('diary.choose-survey')]; } // config lists one or more surveys; find which one to use - const s = getSurveyForTimelineEntry(tripLabelConfig, timelineEntry); + const s = getSurveyForTimelineEntry(tripLabelConfig, timelineEntry, derivedTripProps); const lang = i18n.resolvedLanguage || 'en'; return [s?.surveyName, s?.['not-filled-in-label'][lang]]; }, [appConfig, timelineEntry, i18n.resolvedLanguage]); diff --git a/www/js/survey/enketo/conditionalSurveys.ts b/www/js/survey/enketo/conditionalSurveys.ts index 63f9a9b83..607b49431 100644 --- a/www/js/survey/enketo/conditionalSurveys.ts +++ b/www/js/survey/enketo/conditionalSurveys.ts @@ -1,6 +1,6 @@ import { displayError } from '../../plugin/logger'; import { SurveyButtonConfig } from '../../types/appConfigTypes'; -import { TimelineEntry } from '../../types/diaryTypes'; +import { DerivedProperties, TimelineEntry } from '../../types/diaryTypes'; import { Position } from 'geojson'; const conditionalSurveyFunctions = { @@ -31,6 +31,7 @@ const scopedEval = (script: string, scope: { [k: string]: any }) => export function getSurveyForTimelineEntry( tripLabelConfig: SurveyButtonConfig | SurveyButtonConfig[], tlEntry: TimelineEntry, + derivedProperties: DerivedProperties, ) { // if only one survey is given, just return it if (!(tripLabelConfig instanceof Array)) return tripLabelConfig; @@ -40,6 +41,7 @@ export function getSurveyForTimelineEntry( if (!surveyConfig.showsIf) return surveyConfig; // survey shows unconditionally const scope = { ...tlEntry, + ...derivedProperties, ...conditionalSurveyFunctions, }; try { diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 1caaf18df..5280e7232 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -3,6 +3,7 @@ As much as possible, these types parallel the types used in the server code. */ import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; +import useDerivedProperties from '../diary/useDerivedProperties'; import { MultilabelKey } from './labelTypes'; import { BEMData, LocalDt } from './serverData'; import { FeatureCollection, Feature, Geometry, Point, Position } from 'geojson'; @@ -129,18 +130,7 @@ export type TimestampRange = { start_ts: number; end_ts: number }; /* These properties aren't received from the server, but are derived from the above properties. They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ -export type DerivedProperties = { - displayDate: string; - displayStartTime: string; - displayEndTime: string; - displayTime: string; - displayStartDateAbbr: string; - displayEndDateAbbr: string; - formattedDistance: string; - formattedSectionProperties: any[]; // TODO - distanceSuffix: string; - detectedModes: { mode: string; icon: string; color: string; pct: number | string }[]; -}; +export type DerivedProperties = ReturnType; export type SectionSummary = { count: { [k: MotionTypeKey | BaseModeKey]: number }; From 37fbb851ec9f7e9a989071855790b13a1907405b Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 15 Apr 2024 15:26:02 -0400 Subject: [PATCH 20/20] only use "ranging" scans for BLE matching When matching we basically are trying to find the major:minor pair that was sensed the most frequently during the trip. But should only consider RANGE_UPDATE events for this. The REGION_ENTER and REGION_EXIT events do not have major and minor defined, so we should exclude them for the purpose of matching. Add a filter condition for this. Also updated function + variable names to make it clearer that we are only considering 'ranging' scans. Note that before processing, the value of eventType is a string ('RANGE_UPDATE'). But the server changes this to an enum value where 'RANGE_UPDATE' -> 2. So we have to check for both. --- www/js/survey/inputMatcher.ts | 22 +++++++++++++++------- www/js/types/diaryTypes.ts | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index a02f8ff76..b1460194e 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -372,11 +372,19 @@ function validBleScanForTimelineEntry(tlEntry: TimelineEntry, bleScan: BEMData= entryStart && bleScan.data.ts <= entryEnd; } -function getBleScansForTimelineEntry( +/** + * @description Get BLE scans that are of type RANGE_UPDATE and are within the time range of the timeline entry + */ +function getBleRangingScansForTimelineEntry( tlEntry: TimelineEntry, bleScans: BEMData[], ) { - return bleScans.filter((scan) => validBleScanForTimelineEntry(tlEntry, scan)); + return bleScans.filter( + (scan) => + /* RANGE_UPDATE is the string value, but the server uses an enum, so once processed it becomes 2 */ + (scan.data.eventType == 'RANGE_UPDATE' || scan.data.eventType == 2) && + validBleScanForTimelineEntry(tlEntry, scan), + ); } /** @@ -395,16 +403,16 @@ function decimalToHex(d: string | number, padding?: number) { export function mapBleScansToTimelineEntries(allEntries: TimelineEntry[], appConfig: AppConfig) { const timelineBleMap = {}; for (const tlEntry of allEntries) { - const matches = getBleScansForTimelineEntry(tlEntry, unprocessedBleScans); - if (!matches.length) { + const rangingScans = getBleRangingScansForTimelineEntry(tlEntry, unprocessedBleScans); + if (!rangingScans.length) { continue; } // count the number of occurrences of each major:minor pair const majorMinorCounts = {}; - matches.forEach((match) => { - const major = decimalToHex(match.data.major, 4); - const minor = decimalToHex(match.data.minor, 4); + rangingScans.forEach((scan) => { + const major = decimalToHex(scan.data.major, 4); + const minor = decimalToHex(scan.data.minor, 4); const majorMinor = major + ':' + minor; majorMinorCounts[majorMinor] = majorMinorCounts[majorMinor] ? majorMinorCounts[majorMinor] + 1 diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 5280e7232..9757e95cf 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -168,7 +168,7 @@ export type UserInputEntry = { export type BluetoothBleData = { ts: number; - eventType: 'REGION_ENTER' | 'REGION_EXIT' | 'RANGE_UPDATE'; + eventType: 'REGION_ENTER' | 'REGION_EXIT' | 'RANGE_UPDATE' | number; uuid: string; major: number; // for our use case, missing for REGION_ENTER or REGION_EXIT minor: number; // for our use case, missing for REGION_ENTER or REGION_EXIT