From a72b6fc92ea612820809e7d48f6917cdc27d2bf4 Mon Sep 17 00:00:00 2001 From: Ben Pryhoda Date: Wed, 14 Oct 2020 10:14:07 -0600 Subject: [PATCH] Generic ANT Channel Data Field and Barrel --- .../GenericChannelHeartRateBarrel/.project | 17 ++ .../.settings/IQ_IDE.prefs | 2 + .../GenericChannelHeartRateBarrel/README.md | 2 + .../manifest.xml | 11 + .../monkey.jungle | 2 + .../source/AntPlusHeartRateSensor.mc | 208 ++++++++++++++++++ .../source/HeartRateSensorDelegate.mc | 14 ++ .../source/LegacyHeartData.mc | 25 +++ barrels/README.md | 3 + .../GenericAntPlusHeartRateField/.project | 17 ++ .../GenericAntPlusHeartRateField/README.md | 2 + .../barrels.jungle | 6 + .../GenericAntPlusHeartRateField/manifest.xml | 20 ++ .../monkey.jungle | 1 + .../resources/drawables/drawables.xml | 3 + .../resources/drawables/launcher_icon.png | Bin 0 -> 1474 bytes .../fitcontributions\342\200\213.xml" | 52 +++++ .../resources/resources/resources.xml | 15 ++ .../resources/strings/strings.xml | 13 ++ .../source/FitContributions.mc | 100 +++++++++ .../source/GenericAntPlusHeartRateFieldApp.mc | 22 ++ .../GenericAntPlusHeartRateFieldView.mc | 57 +++++ .../source/HeartRateSensor.mc | 54 +++++ .../source/MinMaxAvg.mc | 52 +++++ .../source/settings/AppSettingsView.mc | 69 ++++++ .../source/settings/CharacterFactory.mc | 56 +++++ .../source/settings/DeviceNumberPicker.mc | 104 +++++++++ .../test/MinMaxAvgTest.mc | 135 ++++++++++++ datafields/README.md | 4 +- 29 files changed, 1065 insertions(+), 1 deletion(-) create mode 100644 barrels/GenericChannelHeartRateBarrel/.project create mode 100644 barrels/GenericChannelHeartRateBarrel/.settings/IQ_IDE.prefs create mode 100644 barrels/GenericChannelHeartRateBarrel/README.md create mode 100644 barrels/GenericChannelHeartRateBarrel/manifest.xml create mode 100644 barrels/GenericChannelHeartRateBarrel/monkey.jungle create mode 100644 barrels/GenericChannelHeartRateBarrel/source/AntPlusHeartRateSensor.mc create mode 100644 barrels/GenericChannelHeartRateBarrel/source/HeartRateSensorDelegate.mc create mode 100644 barrels/GenericChannelHeartRateBarrel/source/LegacyHeartData.mc create mode 100644 datafields/GenericAntPlusHeartRateField/.project create mode 100644 datafields/GenericAntPlusHeartRateField/README.md create mode 100644 datafields/GenericAntPlusHeartRateField/barrels.jungle create mode 100644 datafields/GenericAntPlusHeartRateField/manifest.xml create mode 100644 datafields/GenericAntPlusHeartRateField/monkey.jungle create mode 100644 datafields/GenericAntPlusHeartRateField/resources/drawables/drawables.xml create mode 100644 datafields/GenericAntPlusHeartRateField/resources/drawables/launcher_icon.png create mode 100644 "datafields/GenericAntPlusHeartRateField/resources/fitcontributions/fitcontributions\342\200\213.xml" create mode 100644 datafields/GenericAntPlusHeartRateField/resources/resources/resources.xml create mode 100644 datafields/GenericAntPlusHeartRateField/resources/strings/strings.xml create mode 100644 datafields/GenericAntPlusHeartRateField/source/FitContributions.mc create mode 100644 datafields/GenericAntPlusHeartRateField/source/GenericAntPlusHeartRateFieldApp.mc create mode 100644 datafields/GenericAntPlusHeartRateField/source/GenericAntPlusHeartRateFieldView.mc create mode 100644 datafields/GenericAntPlusHeartRateField/source/HeartRateSensor.mc create mode 100644 datafields/GenericAntPlusHeartRateField/source/MinMaxAvg.mc create mode 100644 datafields/GenericAntPlusHeartRateField/source/settings/AppSettingsView.mc create mode 100644 datafields/GenericAntPlusHeartRateField/source/settings/CharacterFactory.mc create mode 100644 datafields/GenericAntPlusHeartRateField/source/settings/DeviceNumberPicker.mc create mode 100644 datafields/GenericAntPlusHeartRateField/test/MinMaxAvgTest.mc diff --git a/barrels/GenericChannelHeartRateBarrel/.project b/barrels/GenericChannelHeartRateBarrel/.project new file mode 100644 index 0000000..e87a37f --- /dev/null +++ b/barrels/GenericChannelHeartRateBarrel/.project @@ -0,0 +1,17 @@ + + + GenericChannelHeartRateBarrel + + + + + + connectiq.barrelBuilder + + + + + + connectiq.barrelProjectNature + + diff --git a/barrels/GenericChannelHeartRateBarrel/.settings/IQ_IDE.prefs b/barrels/GenericChannelHeartRateBarrel/.settings/IQ_IDE.prefs new file mode 100644 index 0000000..10bb291 --- /dev/null +++ b/barrels/GenericChannelHeartRateBarrel/.settings/IQ_IDE.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +project_manifest=manifest.xml diff --git a/barrels/GenericChannelHeartRateBarrel/README.md b/barrels/GenericChannelHeartRateBarrel/README.md new file mode 100644 index 0000000..e19b815 --- /dev/null +++ b/barrels/GenericChannelHeartRateBarrel/README.md @@ -0,0 +1,2 @@ +# GenericChannelHeartRateBarrel +This barrel demonstrates how to use the Connect IQ Generic ANT Channel module to connect to an ANT+ Heart Rate monitor. This project can be adapted to work with any ANT or ANT+ device. See the [Generic ANT+ Heart Rate Data Field](https://github.com/garmin/connectiq-apps/tree/master/datafields/GenericAntPlusHeartRateField) for an example project that uses this barrel. diff --git a/barrels/GenericChannelHeartRateBarrel/manifest.xml b/barrels/GenericChannelHeartRateBarrel/manifest.xml new file mode 100644 index 0000000..27d7b06 --- /dev/null +++ b/barrels/GenericChannelHeartRateBarrel/manifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/barrels/GenericChannelHeartRateBarrel/monkey.jungle b/barrels/GenericChannelHeartRateBarrel/monkey.jungle new file mode 100644 index 0000000..45c1bf8 --- /dev/null +++ b/barrels/GenericChannelHeartRateBarrel/monkey.jungle @@ -0,0 +1,2 @@ +project.manifest = manifest.xml + diff --git a/barrels/GenericChannelHeartRateBarrel/source/AntPlusHeartRateSensor.mc b/barrels/GenericChannelHeartRateBarrel/source/AntPlusHeartRateSensor.mc new file mode 100644 index 0000000..0f13c9b --- /dev/null +++ b/barrels/GenericChannelHeartRateBarrel/source/AntPlusHeartRateSensor.mc @@ -0,0 +1,208 @@ +using Toybox.Ant; + +module GenericChannelHeartRateBarrel { + + class AntPlusHeartRateSensor extends Toybox.Ant.GenericChannel { + // Channel configuration + private const CHANNEL_PERIOD = 8070; // ANT+ HR Channel Period + private const DEVICE_TYPE = 120; // ANT+ HR Device Type + private const RADIO_FREQUENCY = 57; // ANT+ Radio Frequency + private const SEARCH_TIMEOUT = 1; // 2.5 second search timeout + private const DISABLED = 0; + + // Message indexes + private const MESSAGE_ID_INDEX = 0; + private const MESSAGE_CODE_INDEX = 1; + + // Proximity bin defines + private const WILDCARD_PAIRING = 0; + private const CLOSEST_SEARCH_BIN = 1; + private const FARTHEST_SEARCH_BIN = 10; + private const PROXIMITY_DISABLED = 0; + + // Variables + hidden var chanAssign; + hidden var deviceCfg; + hidden var deviceNumber; + hidden var transmissionType; + hidden var searchThreshold; + hidden var hrSensorDelegate; + hidden var onUpdateCallback; + hidden var onPairedCallback; + hidden var isClosed; // Tracks when the app wants us to stay closed + hidden var isPaired; // Paired is an event we only fire once + + var data; + + // Initializes AntPlusHeartRateSensor, configures and opens channel + // @param extendedDeviceNumber, a 20-bit ANT+ defined integer used for identification + // @param isProximityPairing, true enables pairing based on signal strength from strongest to weakest + function initialize( extendedDeviceNumber, isProximityPairing ) { + + if (extendedDeviceNumber == WILDCARD_PAIRING) { + deviceNumber = WILDCARD_PAIRING; + transmissionType = WILDCARD_PAIRING; + } else { + parseExtendedDeviceNumber( extendedDeviceNumber ); + } + + if ( isProximityPairing ) { + searchThreshold = CLOSEST_SEARCH_BIN; + } else { + searchThreshold = WILDCARD_PAIRING; + } + + data = new LegacyHeartData(); + + // Create channel assignment + chanAssign = new Toybox.Ant.ChannelAssignment( + Toybox.Ant.CHANNEL_TYPE_RX_NOT_TX, + Toybox.Ant.NETWORK_PLUS); + + // Initialize the channel through the superclass + GenericChannel.initialize( method(:onMessage), chanAssign ); + + // Set the configuration + deviceCfg = new Toybox.Ant.DeviceConfig( { + :deviceNumber => deviceNumber, + :deviceType => DEVICE_TYPE, + :transmissionType => transmissionType, + :messagePeriod => CHANNEL_PERIOD, + :radioFrequency => RADIO_FREQUENCY, + :searchTimeoutLowPriority => SEARCH_TIMEOUT, + :searchTimeoutHighPriority => DISABLED, + :searchThreshold => searchThreshold} ); + GenericChannel.setDeviceConfig( deviceCfg ); + + // The channel was initialized into a CLOSED state + isClosed = true; + + // The channel has not paired with a device yet + isPaired = false; + + hrSensorDelegate = null; + onUpdateCallback = null; + } + + // Opens the generic channel + function open() { + isClosed = false; // Externally opening the channel means it is no longer CLOSED + deviceCfg.searchThreshold = searchThreshold; + GenericChannel.setDeviceConfig( deviceCfg ); + GenericChannel.open(); + } + + // Closes the generic channel + function close() { + isClosed = true; // Externally closing the channel means it will stay CLOSED + GenericChannel.close(); + } + + // Release the generic channel + // Once the channel is released it cannot be re-opened or closed again + function release() { + GenericChannel.release(); + } + + // Sets the delegate handler for asynchronous sensor events + // An application can only have 1 registered delegate. Subsequent calls to this function will override the current delegate. + // Setting this to null will remove any registered delegate. + function setDelegate( hrSensorDelegate ) { + hrSensorDelegate = hrSensorDelegate; + + if ( hrSensorDelegate != null ) { + onUpdateCallback = hrSensorDelegate.method(:onHeartRateSensorUpdate); + onPairedCallback = hrSensorDelegate.method(:onHeartRateSensorPaired); + } else { + onUpdateCallback = null; + onPairedCallback = null; + } + } + + // Returns the current extended device number. + // This will change to the sensor's value once paired. + function getExtendedDeviceNumber () { + return (deviceNumber | ((transmissionType & 0xF0) << 12)); + } + + // On new ANT Message, parses the message + // @param msg, a Toybox.Ant.Message object + function onMessage( msg ) { + // Parse the payload + var payload = msg.getPayload(); + + if ( Toybox.Ant.MSG_ID_CHANNEL_RESPONSE_EVENT == msg.messageId ) { + if ( Toybox.Ant.MSG_ID_RF_EVENT == payload[MESSAGE_ID_INDEX] ) { + // React to changes in the ANT channel state + switch(payload[MESSAGE_CODE_INDEX]) { + + // Drop to search occurs after 2s elapse or 8 RX_FAIL events, whichever comes first + case Toybox.Ant.MSG_CODE_EVENT_RX_FAIL_GO_TO_SEARCH: + // Reset HR data after missing over 2s of messages + data.reset(); + if ( onUpdateCallback != null ) { + onUpdateCallback.invoke(data.computedHeartRate); + } + break; + + // Search timeout occurs after SEARCH_TIMEOUT duration passes without pairing + case Toybox.Ant.MSG_CODE_EVENT_RX_SEARCH_TIMEOUT: + // Only change the search threshold if proximity pairing is enabled + if ( searchThreshold != PROXIMITY_DISABLED ) { + // Expand search radius after each channel close event due to search timeout + if ( searchThreshold < FARTHEST_SEARCH_BIN ) { + searchThreshold++; + } else { + // Pair to any signal strength if we've searched every bin + searchThreshold = WILDCARD_PAIRING; + } + + } + break; + + // Close event occurs after a search timeout or if it was requested + case Toybox.Ant.MSG_CODE_EVENT_CHANNEL_CLOSED: + // Reset HR data after the channel closes + data.reset(); + + if ( onUpdateCallback != null ) { + onUpdateCallback.invoke(data.computedHeartRate); + } + + // If ANT closed the channel, re-open it to continue pairing + if(!isClosed) { + open(); + } + break; + } + } + + } else if ( Toybox.Ant.MSG_ID_BROADCAST_DATA == msg.messageId ) { + data.parse( payload ); // Parse payload into data + + if ( onUpdateCallback != null ) { + onUpdateCallback.invoke(data.computedHeartRate); // Pass data to callback + } + + if ( !isPaired ) { + isPaired = true; // Only fire paired event once + + deviceNumber = msg.deviceNumber; + transmissionType = msg.transmissionType; + + if ( onPairedCallback != null ) { + onPairedCallback.invoke(getExtendedDeviceNumber()); + } + } + } + } + + // Parses the 20-bit extended device number into its two separate components + // @param extendedDeviceNumber, a 20-bit ANT+ defined integer used for identification + private function parseExtendedDeviceNumber( extendedDeviceNumber ) { + // Parse the extended device number for the upper nibble + transmissionType = ((extendedDeviceNumber >> 12) & 0xF0) | 0x01; + deviceNumber = extendedDeviceNumber & 0xFFFF; + } + } +} diff --git a/barrels/GenericChannelHeartRateBarrel/source/HeartRateSensorDelegate.mc b/barrels/GenericChannelHeartRateBarrel/source/HeartRateSensorDelegate.mc new file mode 100644 index 0000000..40a74c7 --- /dev/null +++ b/barrels/GenericChannelHeartRateBarrel/source/HeartRateSensorDelegate.mc @@ -0,0 +1,14 @@ +module GenericChannelHeartRateBarrel { + + // Delegate Class for ANT+ Heart Rate Sensor Callbacks. + class HeartRateSensorDelegate { + + // If the sensor is being tracked this will be called with the latest data. + function onHeartRateSensorUpdate( computedHeartRate ) { + } + + // If the extended device number was wildcarded at initialization this will be called with the paired value. + function onHeartRateSensorPaired( extendedDeviceNumber ) { + } + } +} \ No newline at end of file diff --git a/barrels/GenericChannelHeartRateBarrel/source/LegacyHeartData.mc b/barrels/GenericChannelHeartRateBarrel/source/LegacyHeartData.mc new file mode 100644 index 0000000..2a539ef --- /dev/null +++ b/barrels/GenericChannelHeartRateBarrel/source/LegacyHeartData.mc @@ -0,0 +1,25 @@ +module GenericChannelHeartRateBarrel { + + // Represents the data available from any ANT+ Heart Rate strap + class LegacyHeartData { + private static const COMPUTED_HR_INDEX = 7; + private static const INVALID_HR = 0; + + var computedHeartRate; + + function initialize() { + computedHeartRate = INVALID_HR; + } + + // Parses the computed heart rate value from the sensor + // @param payload, application data from an ANT broadcast message + function parse( payload ) { + computedHeartRate = payload[COMPUTED_HR_INDEX]; + } + + // Sets the computed heart rate value to INVALID + function reset() { + computedHeartRate = INVALID_HR; + } + } +} \ No newline at end of file diff --git a/barrels/README.md b/barrels/README.md index 9f7c0f8..32ee226 100644 --- a/barrels/README.md +++ b/barrels/README.md @@ -14,3 +14,6 @@ The Semicircles barrel provides an abstract coordinate type that speeds up posit ### **[BluetoothMeshBarrel](https://github.com/connectiq-apps/tree/master/barrels/BluetoothMeshBarrel)** This Bluetooth Mesh barrel provides an abstract library for connecting your Connect IQ app to a Bluetooth Mesh network. See the [blog post](https://forums.garmin.com/developer/connect-iq/b/news-announcements/posts/bluetooth-mesh-networking-with-connect-iq) for details. + +### **[GenericChannelHeartRateBarrel](https://github.com/connectiq-apps/tree/master/barrels/GenericChannelHeartRateBarrel)** +This barrel demonstrates how to use the Connect IQ Generic ANT Channel module to connect to an ANT+ Heart Rate monitor. This project can be adapted to work with any ANT or ANT+ device. See the [Generic ANT+ Heart Rate Data Field](https://github.com/garmin/connectiq-apps/tree/master/datafields/GenericAntPlusHeartRateField) for an example project that uses this barrel. diff --git a/datafields/GenericAntPlusHeartRateField/.project b/datafields/GenericAntPlusHeartRateField/.project new file mode 100644 index 0000000..fa1bb63 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/.project @@ -0,0 +1,17 @@ + + + GenericAntPlusHeartRateField + + + + + + connectiq.builder + + + + + + connectiq.projectNature + + diff --git a/datafields/GenericAntPlusHeartRateField/README.md b/datafields/GenericAntPlusHeartRateField/README.md new file mode 100644 index 0000000..c4d770d --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/README.md @@ -0,0 +1,2 @@ +# GenericAntPlusHeartRateField +A Connect IQ Simple Data Field that uses the [Generic Channel Heart Rate Barrel](https://github.com/garmin/connectiq-apps/tree/master/barrels/GenericChannelHeartRateBarrel) to connect to an ANT+ Heart Rate Monitor. This data field demonstrates using barrels, app settings, on-device app settings, FIT Developer Fields, and unit tests. diff --git a/datafields/GenericAntPlusHeartRateField/barrels.jungle b/datafields/GenericAntPlusHeartRateField/barrels.jungle new file mode 100644 index 0000000..f5f7841 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/barrels.jungle @@ -0,0 +1,6 @@ +# Do not hand edit this file. To make changes right click +# on the project and select "Configure Monkey Barrels". + +GenericChannelHeartRateBarrel = [../../barrels/GenericChannelHeartRateBarrel/monkey.jungle] +base.barrelPath = $(base.barrelPath);$(GenericChannelHeartRateBarrel) + diff --git a/datafields/GenericAntPlusHeartRateField/manifest.xml b/datafields/GenericAntPlusHeartRateField/manifest.xml new file mode 100644 index 0000000..3ad4670 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/manifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + eng + + + + + + diff --git a/datafields/GenericAntPlusHeartRateField/monkey.jungle b/datafields/GenericAntPlusHeartRateField/monkey.jungle new file mode 100644 index 0000000..87796c7 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/monkey.jungle @@ -0,0 +1 @@ +project.manifest = manifest.xml diff --git a/datafields/GenericAntPlusHeartRateField/resources/drawables/drawables.xml b/datafields/GenericAntPlusHeartRateField/resources/drawables/drawables.xml new file mode 100644 index 0000000..a22c33c --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/resources/drawables/drawables.xml @@ -0,0 +1,3 @@ + + + diff --git a/datafields/GenericAntPlusHeartRateField/resources/drawables/launcher_icon.png b/datafields/GenericAntPlusHeartRateField/resources/drawables/launcher_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3f01f99c6f70cc21bde65d8abf217d6a3268b179 GIT binary patch literal 1474 zcmV;z1wHzSP)j>CA1AypgrK?*%u^Hub<;(9Q71$W6@t_&5q(fz z7)DtfYBcBz@**lIqARPB%9(~lFjI20X{s@2oEcx&xi~3~j*jwj7VMk7|9k!R+G{;~ z??(Za0Dl7i1m*#w{C`aS?fV(G)mnBP<5DJCp>FL4m_Y;jqQ?z=$9=F?#%jF_FJDcy{zw_zSCq91s$l%~$ zy68tWAORRB_S)@sN=izosHkB6{P~1JA?$WLdcB?t7cOw`-o0^$^XAQ?q@;vn$Btn% z8u|M5D>j=AfX2o~?%%&pFc_Q+j_QDa1BS64qtVEoJ$tCAsGz;QorZ=6Zr!>?Yilb8 zgMqz!_mWH|>Fn%eWMqW0vNBGeK8?@kqpq%wn>TOr>eVYWO=HK79T*G-K79DV@bK_N z@G!v7iThtvR3xpftx{K4Cr+m`B{w#kO=@at?yWR5e;X^rd=8R-zWu<_%iSe7wW}HqZ@7}$mv$Hc@QSj~CHyRom=ka&vQWI2(|TEr%%P> z@yPP!%V#dGv9@yMO1XXew!C=pLN;#PI48KGD6(eF8fk8BmPd~s$%YLZ(&^HJ0Vyab zkd~GfdGO$YxZUnqgIz9{T)lc#UcP)O#l^+b>81w*vU&4ndH(#lG&eWPvSrIMLKiJs zB$qB-lDBW)N=Zq{Oxl^jfNb5mRlHuWT)TEna&mH})!A$|IeYf3ba!{l?%lgHEiyA0 zknP*IOCS)C`uh4Qrx_q7lSyi8Yb6*A%KrWPXOoiIfdSdIYnOC%bjZ1L=fq;MjQ6Xm zD#wo>mw|x+Idtey+7s+=f%SU5?Ay0bdV722T3D*>z70#5xIEr zqGV@h&jl*LPaSZa3HNa(i^t>m{eCn}qr1DC^XJdg)6+BC>Emvc-w|8pI#bjBcfX8` zjLdZ^Kt#)|0Yy=!+(46oilP8ads9uejNttIeDd@2X=!O8nM`6bnOM7aEh55?A3w;; z%foKBqtodC2!%p;z20>1ths^9%E~x$46un+gB9TBu7#tiV9*kZXoyH8g5U2a5D4J+`?+)H4qmU9j*bqJ$s}*yyus)5 z%>@3S06D-F;2)& + + + + + + + + + + + + + + \ No newline at end of file diff --git a/datafields/GenericAntPlusHeartRateField/resources/resources/resources.xml b/datafields/GenericAntPlusHeartRateField/resources/resources/resources.xml new file mode 100644 index 0000000..23c89a4 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/resources/resources/resources.xml @@ -0,0 +1,15 @@ + + + 0 + false + + + + + + + + + + + \ No newline at end of file diff --git a/datafields/GenericAntPlusHeartRateField/resources/strings/strings.xml b/datafields/GenericAntPlusHeartRateField/resources/strings/strings.xml new file mode 100644 index 0000000..6c5613a --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/resources/strings/strings.xml @@ -0,0 +1,13 @@ + + GenericAntPlusHeartRateField + Heart Rate + BPM + Avg HR + Min HR + Max HR + ANT Sensor ID + Proximity Pairing + Done + Del + + diff --git a/datafields/GenericAntPlusHeartRateField/source/FitContributions.mc b/datafields/GenericAntPlusHeartRateField/source/FitContributions.mc new file mode 100644 index 0000000..1de6ee2 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/source/FitContributions.mc @@ -0,0 +1,100 @@ +using Toybox.WatchUi; +using Toybox.FitContributor as Fit; + +const HEART_RATE_FIELD_RECORD_ID = 0; +const HEART_RATE_FIELD_SESSION_MIN_ID = 1; +const HEART_RATE_FIELD_SESSION_MAX_ID = 2; +const HEART_RATE_FIELD_SESSION_AVG_ID = 3; +const HEART_RATE_FIELD_LAP_MIN_ID = 4; +const HEART_RATE_FIELD_LAP_MAX_ID = 5; +const HEART_RATE_FIELD_LAP_AVG_ID = 6; + +const HEART_RATE_NATIVE_NUM_RECORD_MESG = 3; + +const HEART_RATE_NATIVE_NUM_SESSION_MIN_MESG = 64; +const HEART_RATE_NATIVE_NUM_SESSION_MAX_MESG = 17; +const HEART_RATE_NATIVE_NUM_SESSION_AVG_MESG = 16; + +const HEART_RATE_NATIVE_NUM_LAP_MIN_MESG = 63; +const HEART_RATE_NATIVE_NUM_LAP_MAX_MESG = 16; +const HEART_RATE_NATIVE_NUM_LAP_AVG_MESG = 15; + +const HEART_RATE_UNITS = "BPM"; + +class FitContributions { + + hidden var mHeartRateRecordField; + hidden var mMinHeartRateSessionField; + hidden var mMaxHeartRateSessionField; + hidden var mAvgHeartRateSessionField; + hidden var mMinHeartRateLapField; + hidden var mMaxHeartRateLapField; + hidden var mAvgHeartRateLapField; + + hidden var mTimerRunning = false; + hidden var mSessionStats; + hidden var mLapStats; + + function initialize(dataField) { + + mHeartRateRecordField = dataField.createField("heart_rate", HEART_RATE_FIELD_RECORD_ID, Fit.DATA_TYPE_UINT8, { :nativeNum=>HEART_RATE_NATIVE_NUM_RECORD_MESG, :mesgType=>Fit.MESG_TYPE_RECORD, :units=>HEART_RATE_UNITS }); + + mMinHeartRateSessionField = dataField.createField("min_heart_rate", HEART_RATE_FIELD_SESSION_MIN_ID, Fit.DATA_TYPE_UINT8, { :nativeNum=>HEART_RATE_NATIVE_NUM_SESSION_MIN_MESG, :mesgType=>Fit.MESG_TYPE_SESSION, :units=>HEART_RATE_UNITS }); + mMaxHeartRateSessionField = dataField.createField("max_heart_rate", HEART_RATE_FIELD_SESSION_MAX_ID, Fit.DATA_TYPE_UINT8, { :nativeNum=>HEART_RATE_NATIVE_NUM_SESSION_MAX_MESG, :mesgType=>Fit.MESG_TYPE_SESSION, :units=>HEART_RATE_UNITS }); + mAvgHeartRateSessionField = dataField.createField("avg_heart_rate", HEART_RATE_FIELD_SESSION_AVG_ID, Fit.DATA_TYPE_UINT8, { :nativeNum=>HEART_RATE_NATIVE_NUM_SESSION_AVG_MESG, :mesgType=>Fit.MESG_TYPE_SESSION, :units=>HEART_RATE_UNITS }); + + mMinHeartRateLapField = dataField.createField("min_heart_rate", HEART_RATE_FIELD_LAP_MIN_ID, Fit.DATA_TYPE_UINT8, { :nativeNum=>HEART_RATE_NATIVE_NUM_LAP_MIN_MESG, :mesgType=>Fit.MESG_TYPE_LAP, :units=>HEART_RATE_UNITS }); + mMaxHeartRateLapField = dataField.createField("max_heart_rate", HEART_RATE_FIELD_LAP_MAX_ID, Fit.DATA_TYPE_UINT8, { :nativeNum=>HEART_RATE_NATIVE_NUM_LAP_MAX_MESG, :mesgType=>Fit.MESG_TYPE_LAP, :units=>HEART_RATE_UNITS }); + mAvgHeartRateLapField = dataField.createField("avg_heart_rate", HEART_RATE_FIELD_LAP_AVG_ID, Fit.DATA_TYPE_UINT8, { :nativeNum=>HEART_RATE_NATIVE_NUM_LAP_AVG_MESG, :mesgType=>Fit.MESG_TYPE_LAP, :units=>HEART_RATE_UNITS }); + + mSessionStats = new MinMaxAvg(false); + mLapStats = new MinMaxAvg(false); + } + + function setHeartRateData(heartrate) { + mHeartRateRecordField.setData(heartrate > 0 ? heartrate : 0xFF); + + if(mTimerRunning) { + mSessionStats.setData(heartrate); + mLapStats.setData(heartrate); + + mMinHeartRateSessionField.setData(mSessionStats.min()); + mMaxHeartRateSessionField.setData(mSessionStats.max()); + mAvgHeartRateSessionField.setData(mSessionStats.avg()); + + mMinHeartRateLapField.setData(mSessionStats.min()); + mMaxHeartRateLapField.setData(mSessionStats.max()); + mAvgHeartRateLapField.setData(mSessionStats.avg()); + } + } + + function onNextMultisportLeg() { + mSessionStats.reset(); + mLapStats.reset(); + } + + function onTimerLap() { + mLapStats.reset(); + } + + function onTimerReset() { + mSessionStats.reset(); + mLapStats.reset(); + } + + function onTimerPause() { + mTimerRunning = false; + } + + function onTimerResume() { + mTimerRunning = true; + } + + function onTimerStart() { + mTimerRunning = true; + } + + function onTimerStop() { + mTimerRunning = false; + } +} diff --git a/datafields/GenericAntPlusHeartRateField/source/GenericAntPlusHeartRateFieldApp.mc b/datafields/GenericAntPlusHeartRateField/source/GenericAntPlusHeartRateFieldApp.mc new file mode 100644 index 0000000..371b52e --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/source/GenericAntPlusHeartRateFieldApp.mc @@ -0,0 +1,22 @@ +using Toybox.Application; + +class GenericAntPlusHeartRateFieldApp extends Application.AppBase { + + function initialize() { + AppBase.initialize(); + } + + // Return the initial view of your application here + function getInitialView() { + return [ new GenericAntPlusHeartRateFieldView() ]; + } + + // Triggered by settings change in GCM + function onSettingsChanged() { + HeartRateSensor.getInstance().pair(); + } + + function getSettingsView() { + return [ new AppSettingsView(), new AppSettingsDelegate() ]; + } +} diff --git a/datafields/GenericAntPlusHeartRateField/source/GenericAntPlusHeartRateFieldView.mc b/datafields/GenericAntPlusHeartRateField/source/GenericAntPlusHeartRateFieldView.mc new file mode 100644 index 0000000..4ad4998 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/source/GenericAntPlusHeartRateFieldView.mc @@ -0,0 +1,57 @@ +using Toybox.WatchUi; + +class GenericAntPlusHeartRateFieldView extends WatchUi.SimpleDataField { + + hidden var mFitContributions; + hidden var mHrChannel; + + // Set the label of the data field here. + function initialize() { + SimpleDataField.initialize(); + label = "Heart Rate 2"; + + mFitContributions = new FitContributions(self); + + HeartRateSensor.getInstance().pair(); + + } + + // The given info object contains all the current workout + // information. Calculate a value and return it in this method. + // Note that compute() and onUpdate() are asynchronous, and there is no + // guarantee that compute() will be called before onUpdate(). + function compute(info) { + // See Activity.Info in the documentation for available information. + var heartRate = HeartRateSensor.getInstance().getHeartRate(); + mFitContributions.setHeartRateData(heartRate); + return heartRate > 0 ? heartRate : "--"; + } + + function onNextMultisportLeg() { + mFitContributions.onNextMultisportLeg(); + } + + function onTimerLap() { + mFitContributions.onTimerLap(); + } + + function onTimerReset() { + mFitContributions.onTimerReset(); + } + + function onTimerPause() { + mFitContributions.onTimerPause(); + } + + function onTimerResume() { + mFitContributions.onTimerResume(); + } + + function onTimerStart() { + mFitContributions.onTimerStart(); + } + + function onTimerStop() { + mFitContributions.onTimerStop(); + } +} diff --git a/datafields/GenericAntPlusHeartRateField/source/HeartRateSensor.mc b/datafields/GenericAntPlusHeartRateField/source/HeartRateSensor.mc new file mode 100644 index 0000000..419fb70 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/source/HeartRateSensor.mc @@ -0,0 +1,54 @@ +using Toybox.Application; +using GenericChannelHeartRateBarrel as HrBarrel; + +class HeartRateSensor extends HrBarrel.HeartRateSensorDelegate { + + hidden var mHrChannel = null; + hidden var mlastHeartRateValue = null; + + hidden static var instance = null; + static function getInstance() { + if(instance == null) { + instance = new HeartRateSensor(); + } + return instance; + } + + private function initialize() { + HrBarrel.HeartRateSensorDelegate.initialize(); + mlastHeartRateValue = 0; + } + + function pair() { + + if(mHrChannel != null) { + mHrChannel.release(); + mHrChannel = null; + } + + var deviceNumber = Application.getApp().getProperty("deviceNumber"); + var proximityPairing = Application.getApp().getProperty("proximityPairing"); + + try { + mHrChannel = new HrBarrel.AntPlusHeartRateSensor(deviceNumber, proximityPairing); + mHrChannel.setDelegate(self); + mHrChannel.open(); + } + catch (e) { + System.println(e.getErrorMessage()); + System.println(e.printStackTrace()); + } + } + + function getHeartRate() { + return mlastHeartRateValue; + } + + function onHeartRateSensorUpdate( computedHeartRate ) { + mlastHeartRateValue = computedHeartRate; + } + + function onHeartRateSensorPaired( extendedDeviceNumber ) { + Application.getApp().setProperty("deviceNumber", extendedDeviceNumber); + } +} diff --git a/datafields/GenericAntPlusHeartRateField/source/MinMaxAvg.mc b/datafields/GenericAntPlusHeartRateField/source/MinMaxAvg.mc new file mode 100644 index 0000000..613fcc3 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/source/MinMaxAvg.mc @@ -0,0 +1,52 @@ +const MIN_VALUE = -0x7FFFFFFF; +const MAX_VALUE = 0x7FFFFFFF; + +class MinMaxAvg +{ + private var _last; + private var _min; + private var _max; + private var _count; + private var _total; + private var _includeZeros; + + + function initialize(includeZeros) { + _last = null; + _min = MAX_VALUE; + _max = MIN_VALUE; + _count = 0; + _total = 0; + _includeZeros = includeZeros; + } + + function last() { return _last; } + function min() { return _min; } + function max() { return _max; } + function avg() { return _total.toFloat() / ( _count > 0 ? _count : 1); } + function count() { return _count; } + function total() { return _total; } + + function setData(value) { + if(value == 0 && !_includeZeros) { + return self; + } + + _last = value; + _min = value < _min ? value : _min; + _max = value > _max ? value : _max; + + _total += value; + _count++; + + return self; + } + + function reset() { + _last = null; + _min = MAX_VALUE; + _max = MIN_VALUE; + _count = 0; + _total = 0; + } +} diff --git a/datafields/GenericAntPlusHeartRateField/source/settings/AppSettingsView.mc b/datafields/GenericAntPlusHeartRateField/source/settings/AppSettingsView.mc new file mode 100644 index 0000000..37903f3 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/source/settings/AppSettingsView.mc @@ -0,0 +1,69 @@ +using Toybox.WatchUi; + +class AppSettingsDelegate extends WatchUi.Menu2InputDelegate { + hidden var mMenu; + function initialize() { + Menu2InputDelegate.initialize(); + } + + function onSelect(item) { + if( item.getId().equals("deviceNumber") ) { + var deviceNumberPicker = new DeviceNumberPicker(); + WatchUi.pushView(deviceNumberPicker, new DeviceNumberPickerDelegate(deviceNumberPicker), WatchUi.SLIDE_IMMEDIATE ); + } + else if( item.getId().equals("proximityPairing") ) { + Application.getApp().setProperty("proximityPairing", item.isEnabled()); + HeartRateSensor.getInstance().pair(); + } + } + + function onBack() { + WatchUi.popView(WatchUi.SLIDE_DOWN); + } +} + +class AppSettingsView extends WatchUi.Menu2 { + hidden var mDeviceNumber; + + function initialize() { + Menu2.initialize({:title=>"Settings"}); + + mDeviceNumber = Application.getApp().getProperty("deviceNumber"); + + addItem( + new WatchUi.MenuItem( + Rez.Strings.ant_sensor_id, + mDeviceNumber.toString(), + "deviceNumber", + {} + ) + ); + + addItem( + new WatchUi.ToggleMenuItem( + Rez.Strings.proximity_pairing, + null, + "proximityPairing", + Application.getApp().getProperty("proximityPairing"), + {} + ) + ); + } + + function onShow() { + var deviceNumber = Application.getApp().getProperty("deviceNumber"); + if(deviceNumber != mDeviceNumber) { + mDeviceNumber = deviceNumber; + + var item = self.getItem(0); + if(item != null) { + item.setSubLabel(mDeviceNumber.toString()); + self.updateItem(item, 0); + } + + HeartRateSensor.getInstance().pair(); + } + } +} + + \ No newline at end of file diff --git a/datafields/GenericAntPlusHeartRateField/source/settings/CharacterFactory.mc b/datafields/GenericAntPlusHeartRateField/source/settings/CharacterFactory.mc new file mode 100644 index 0000000..33b6fc7 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/source/settings/CharacterFactory.mc @@ -0,0 +1,56 @@ +using Toybox.Graphics; +using Toybox.WatchUi; + +class CharacterFactory extends WatchUi.PickerFactory { + hidden var mCharacterSet; + hidden var mAddDone; + hidden var mAddDelete; + const DONE = -1; + const DELETE = -2; + + function initialize(characterSet, options) { + PickerFactory.initialize(); + mCharacterSet = characterSet; + mAddDone = (null != options) and (options.get(:addDone) == true); + mAddDelete = (null != options) and (options.get(:addDelete) == true); + } + + function getIndex(value) { + var index = mCharacterSet.find(value); + return index; + } + + function getSize() { + return mCharacterSet.length() + ( mAddDone ? 1 : 0 ) + ( mAddDelete ? 1 : 0 ); + } + + function getValue(index) { + if(index == mCharacterSet.length() and mAddDone) { + return DONE; + } + else if(index >= mCharacterSet.length()) { + return DELETE; + } + + return mCharacterSet.substring(index, index+1); + } + + function getDrawable(index, selected) { + if(index == mCharacterSet.length() and mAddDone) { + return new WatchUi.Text( {:text=>Rez.Strings.characterPickerDone, :color=>Graphics.COLOR_WHITE, :font=>Graphics.FONT_LARGE, :locX =>WatchUi.LAYOUT_HALIGN_CENTER, :locY=>WatchUi.LAYOUT_VALIGN_CENTER } ); + } + else if(index >= mCharacterSet.length()) { + return new WatchUi.Text( {:text=>Rez.Strings.characterPickerBackspace, :color=>Graphics.COLOR_WHITE, :font=>Graphics.FONT_LARGE, :locX =>WatchUi.LAYOUT_HALIGN_CENTER, :locY=>WatchUi.LAYOUT_VALIGN_CENTER } ); + } + + return new WatchUi.Text( { :text=>getValue(index), :color=>Graphics.COLOR_WHITE, :font=> Graphics.FONT_LARGE, :locX =>WatchUi.LAYOUT_HALIGN_CENTER, :locY=>WatchUi.LAYOUT_VALIGN_CENTER } ); + } + + function isDone(value) { + return mAddDone and (value == DONE); + } + + function isDelete(value) { + return mAddDelete and (value == DELETE); + } +} diff --git a/datafields/GenericAntPlusHeartRateField/source/settings/DeviceNumberPicker.mc b/datafields/GenericAntPlusHeartRateField/source/settings/DeviceNumberPicker.mc new file mode 100644 index 0000000..a7a6553 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/source/settings/DeviceNumberPicker.mc @@ -0,0 +1,104 @@ +using Toybox.Application; +using Toybox.Graphics; +using Toybox.WatchUi; + +class DeviceNumberPicker extends WatchUi.Picker { + const mCharacterSet = "0123456789"; + hidden var mTitleText; + hidden var mFactory; + + function initialize() { + mFactory = new CharacterFactory(mCharacterSet, {:addDone=>true, :addDelete=>true}); + mTitleText = ""; + + var string = Application.getApp().getProperty("deviceNumber").toString(); + var defaults = null; + var titleText = Rez.Strings.ant_sensor_id; + + if(string != null) { + mTitleText = string; + titleText = string; + defaults = [mFactory.getIndex(string.substring(string.length()-1, string.length()))]; + } + + mTitle = new WatchUi.Text({:text=>titleText, :locX =>WatchUi.LAYOUT_HALIGN_CENTER, :locY=>WatchUi.LAYOUT_VALIGN_BOTTOM, :color=>Graphics.COLOR_WHITE}); + + Picker.initialize({:title=>mTitle, :pattern=>[mFactory], :defaults=>defaults}); + } + + function onUpdate(dc) { + dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK); + dc.clear(); + Picker.onUpdate(dc); + } + + function addCharacter(character) { + mTitleText += character; + mTitle.setText(mTitleText); + } + + function removeCharacter() { + mTitleText = mTitleText.substring(0, mTitleText.length() - 1); + + if(0 == mTitleText.length()) { + mTitle.setText(WatchUi.loadResource(Rez.Strings.ant_sensor_id)); + } + else { + mTitle.setText(mTitleText); + } + } + + function getTitle() { + return mTitleText.toString(); + } + + function getTitleLength() { + return mTitleText.length(); + } + + function isDone(value) { + return mFactory.isDone(value); + } + + function isDelete(value) { + return mFactory.isDelete(value); + } +} + +class DeviceNumberPickerDelegate extends WatchUi.PickerDelegate { + hidden var mPicker; + + function initialize(picker) { + PickerDelegate.initialize(); + mPicker = picker; + } + + function onCancel() { + if(0 == mPicker.getTitleLength()) { + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + } + else { + mPicker.removeCharacter(); + } + } + + function onAccept(values) { + if(mPicker.isDelete(values[0])) { + mPicker.removeCharacter(); + } + else if(mPicker.isDone(values[0])) { + if(mPicker.getTitle().length() == 0) { + + Application.getApp().setProperty("deviceNumber", 0); + } + else { + Application.getApp().setProperty("deviceNumber", mPicker.getTitle().toNumber()); + } + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + } + else { + mPicker.addCharacter(values[0]); + } + } + +} diff --git a/datafields/GenericAntPlusHeartRateField/test/MinMaxAvgTest.mc b/datafields/GenericAntPlusHeartRateField/test/MinMaxAvgTest.mc new file mode 100644 index 0000000..62ffe93 --- /dev/null +++ b/datafields/GenericAntPlusHeartRateField/test/MinMaxAvgTest.mc @@ -0,0 +1,135 @@ +using Toybox.Test; + +(:test) +function minmaxavgTestLast(logger) { + + var stats = new MinMaxAvg(true); + stats.setData(1); + stats.setData(2); + stats.setData(3); + + logger.debug("last = " + stats.last()); + return (stats.last() == 3); +} + +(:test) +function minmaxavgTestMin(logger) { + + var stats = new MinMaxAvg(true); + stats.setData(1); + stats.setData(2); + stats.setData(3); + + logger.debug("min = " + stats.min()); + return (stats.min() == 1); +} + +(:test) +function minmaxavgTestMax(logger) { + + var stats = new MinMaxAvg(true); + stats.setData(1); + stats.setData(2); + stats.setData(3); + + logger.debug("max = " + stats.max()); + return (stats.max() == 3); +} + +(:test) +function minmaxavgTestAvg(logger) { + + var stats = new MinMaxAvg(true); + stats.setData(1); + stats.setData(2); + stats.setData(3); + + logger.debug("avg = " + stats.avg()); + return (stats.avg() == 2); +} + +(:test) +function minmaxavgTestCount(logger) { + + var stats = new MinMaxAvg(true); + stats.setData(1); + stats.setData(2); + stats.setData(3); + + logger.debug("count = " + stats.count()); + return (stats.count() == 3); +} + +(:test) +function minmaxavgTestTotal(logger) { + + var stats = new MinMaxAvg(true); + stats.setData(1); + stats.setData(2); + stats.setData(3); + + logger.debug("total = " + stats.total()); + return (stats.total() == 6); +} + +(:test) +function minmaxavgTestAvgWithZeros(logger) { + + var stats = new MinMaxAvg(true); + stats.setData(0); + stats.setData(1); + stats.setData(2); + stats.setData(3); + + logger.debug("avg = " + stats.avg()); + return (stats.avg() == 1.5); +} + +(:test) +function minmaxavgTestAvgWithoutZeros(logger) { + + var stats = new MinMaxAvg(false); + stats.setData(0); + stats.setData(1); + stats.setData(2); + stats.setData(3); + + logger.debug("avg = " + stats.avg()); + return (stats.avg() == 2); +} + +(:test) +function minmaxavgTestMinWithNegativeValues(logger) { + + var stats = new MinMaxAvg(true); + stats.setData(-1); + stats.setData(-2); + stats.setData(-3); + + logger.debug("min = " + stats.min()); + return (stats.min() == -3); +} + +(:test) +function minmaxavgTestMaxWithNegativeValues(logger) { + + var stats = new MinMaxAvg(true); + stats.setData(-1); + stats.setData(-2); + stats.setData(-3); + + logger.debug("max = " + stats.max()); + return (stats.max() == -1); +} + +(:test) +function minmaxavgTestAvgWithNegativeValues(logger) { + + var stats = new MinMaxAvg(true); + stats.setData(-1); + stats.setData(-2); + stats.setData(-3); + + logger.debug("avg = " + stats.avg()); + return (stats.avg() == -2); +} diff --git a/datafields/README.md b/datafields/README.md index a9ca93a..ae2d3aa 100644 --- a/datafields/README.md +++ b/datafields/README.md @@ -2,4 +2,6 @@ Data fields are apps that run within the native activities that allow developers to compute values based off of the current activity, such as running, biking, etc. ## Data Fields Index -_Coming soon..._ + +### [Generic ANT+ Heart Rate Data Field](https://github.com/garmin/connectiq-apps/tree/master/datafields/GenericAntPlusHeartRateField) +A Connect IQ Simple Data Field that uses the [Generic Channel Heart Rate Barrel](https://github.com/garmin/connectiq-apps/tree/master/barrels/GenericChannelHeartRateBarrel) to connect to an ANT+ Heart Rate Monitor. This data field demonstrates using barrels, app settings, on-device app settings, FIT Developer Fields, and unit tests.