How to acquire Stryd's power data?

Hello all,

I'm trying to figure out how to make a datafield that would display the power data from Stryd (I'm using Forerunner 245).

The Stryd Zones datafield somehow manage to display power on this watch.

I've read somewhere that it's possible through SensorInfo datafield somehow acquiring the power data in the Background process, but I can't figure out how to implement that. Does anyone possibly know how to do it?

Best,

Karol

  • No, you wouldn't use a background process in a datafield to acquire power. Background processes run at most every 30 seconds, and I assume you want to update power every second.

    What you want to do is make an ANT+ connection in the datafield foreground process, and read the standard power meter info from the sensor. The easiest (and most memory-efficient) way to implement this would be to require the user to enter the ANT+ ID of their power meter in the app settings. (You could also write code to auto-detect the sensor, I think, but the addiitional code would chew valuable RAM that could used for other things.)

    Unfortunately I don't have an example with clean code that I can share at the moment, but you can start with the Connect IQ SDK MoxyField example, which has an example of both reading from an ANT+ sensor, and recording the data to the activity FIT field. I used that as a starting point for my data field which displays power from meters like Stryd.

    To read the ANT+ power profile, you'll have to sign up at the ANT+ website and download the device profile PDF:

    https://www.thisisant.com/developer/ant-plus/device-profiles#521_tab

    Or if you spend some time googling, you may be able to find sample code.

    (To get the ANT+ ID of your Stryd, connect to it as a foot pod and go to the device menu under sensors.)

  • Eh, I guess I can share what I have. Note that there's a lot of hardcoded constants and minimal use of classes, because I wanted to save as much memory as possible. Some of the original "nice", class-based code is in the comments.

    Sorry the code isn't pretty. I based it on an example, and I didn't beautify it and/or use a consistent style.

    using Toybox.Ant;
    using Toybox.Time;
    
    class PowerSensor extends Ant.GenericChannel {
    	// bike power
        //const DEVICE_TYPE = 11;
        //const PERIOD = 8182;
        //const TRANSMISSION_TYPE = 5;
    
    //    var data;
        var searching;
        var failedInit = false;
        //var pastEventCount;
    
    	var currentPower = null;
    
    //    class PowerData {
    //    	var eventCount;
    //        var currentPower;
    //
    //        function initialize() {
    //            eventCount = 0;
    //            currentPower = null;
    //        }
    //    }
    
    //    class PowerDataPage {
    //        static const PAGE_NUMBER = 0x10; // bike power
    //
    //        function parse(payload, data) {
    //            data.eventCount = parseEventCount(payload);
    //            data.currentPower = parseCurrentPower(payload);
    //        }
    //
    //        hidden function parseEventCount(payload) {
    //           return payload[1];
    //        }
    //
    //
    //        hidden function parseCurrentPower(payload) {
    //           return payload[6] | ((payload[7] & 0xFF) << 8);
    //        }
    //    }
        var deviceNumber;
        var reopen = false;
        function initialize(devNumber) {
        	//System.println("deviceNumber = " +deviceNumber);
    //    	if (deviceNumber == 0)
    //    	{
    //    		return;
    //    	}
        	deviceNumber = devNumber;
    
            // Get the channel
            var chanAssign = new Ant.ChannelAssignment(
                0 /*Ant.CHANNEL_TYPE_RX_NOT_TX*/,
                1 /*Ant.NETWORK_PLUS*/);
    
            try {
                GenericChannel.initialize(method(:onMessage), chanAssign);
            } catch(e instanceof Ant.UnableToAcquireChannelException) {
                //System.println(e.getErrorMessage());
                failedInit = true;
                return;
            }
    
            // Set the configuration
            var deviceCfg = new Ant.DeviceConfig( {
                :deviceNumber => deviceNumber,
                :deviceType => 11, //DEVICE_TYPE,
                :transmissionType => 5, //TRANSMISSION_TYPE,
                :messagePeriod => 8182, //PERIOD,
                :radioFrequency => 57,              //Ant+ Frequency
                :searchTimeoutLowPriority => 10,    //Timeout in 25s
                :searchThreshold => 0} );
    
            GenericChannel.setDeviceConfig(deviceCfg);
    
    
            //data = new PowerData();
            open();
        }
    
        function open() {
            // Open the channel
            GenericChannel.open();
    
            //data = new PowerData();
            //pastEventCount = 0;
    
            currentPower = null;
            searching = true;
        }
    
    //    function closeSensor() {
    //        GenericChannel.close();
    //    }
    
        function onMessage(msg) {
            // Parse the payload
            var payload = msg.getPayload();
            var payload0 = payload[0];
            var payload1 = payload[1];
            if (/*Ant.MSG_ID_BROADCAST_DATA */0x4e == msg.messageId) {
                if (/*PowerDataPage.PAGE_NUMBER*/ 0x10 == payload0) {
                    // Were we searching?
                    //if (searching) {
                        searching = false;
                        // Update our device configuration primarily to see the device number of the sensor we paired to
                        //deviceCfg = GenericChannel.getDeviceConfig();
                    //}
    //                var dp = new PowerDataPage();
    //                dp.parse(msg.getPayload(), data);
    //                // Check if the data has changed
    //                if (pastEventCount != data.eventCount) {
    //                    pastEventCount = data.eventCount;
    //                }
    
                      currentPower = payload[6] | ((payload[7]) << 8);
                }
            } else if (/*Ant.MSG_ID_CHANNEL_RESPONSE_EVENT*/0x40 == msg.messageId) {
                if (/*Ant.MSG_ID_RF_EVENT*/0x01 == payload0) {
                    if (/*Ant.MSG_CODE_EVENT_CHANNEL_CLOSED*/0x07 == payload1) {
                        // Channel closed, re-open
                        reopen = true;
                    } else if (/*Ant.MSG_CODE_EVENT_RX_FAIL_GO_TO_SEARCH*/0x08  == payload1) {
                    	//data.currentPower = null;
                    	currentPower = null;
                        searching = true;
                    }
                } else {
                    //It is a channel response.
                }
            }
        }
    }

    Note that the "reopen" logic is necessary to work around a quirk/bug where the ANT+ channel can suddenly close in the middle of an activity.

    EDIT: The "reopen" logic (most of which is external to that snippet), is as follows:

    - Whoever owns the PowerSensor instance has to regularly check the reopen flag (same as it would check currentPower) -- in this case it would be the data field view.

    - If reopen == true, then it should destroy the instance (by setting all references to null), and create a new one from scratch (as if the app was initialized for the first time.)

  • Thank you very much! That's really helpful.

    I'm trying to implement that into my code, but since I'm fairly new to this, I have encountered some problems.

    I'm creating the object pSensor in the method onStart of the class that "extends Application.AppBase", where ANT_ID is the ID of my sensor:

    // onStart() is called on application start up
        function onStart(state) {
    		try {
                //Create the sensor object and open it
                pSensor = new PowerSensor(ANT_ID);
                pSensor.open();
            } catch(e instanceof Ant.UnableToAcquireChannelException) {
                System.println(e.getErrorMessage());
                pSensor = null;
            }
        }

    I try to debug with Simulation -> FIT Data -> Simulate Data (I've read that it randomly creates sensor's data).

    It seems that the function onMessage(msg) is never called.

    Could you tell me how do you do debugging in those circumstances? Also, is it required to call the onMessage(msg) function somewhere?

  • You're welcome!

    No, onMessage() is an event handler that's automatically called when a message is received from the connected sensor. Everything you need is in that snippet, except for the external "reopen" implementation.

    I don't think you can debug it with Simulate Data, because that simulates activity data (some of which represents sensor data collected *natively*), not raw data from ANT+ sensors. For example, if your code looked at ActivityInfo.currentPower, then you would see that data in the simulation. But of course, you aren't going to use currentPower from ActivityInfo because that isn't available for the 245 (or any non-multisport watch). (Note that the simulator has no way to configure sensors, such as setting the ANT ID of a hypothetical simulated sensor.)

    I think you have to sideload the app to your watch and connect it to your Stryd for real. Or you could buy an ANT+ USB stick and test that way.

    But either way I think you need to test with the real power meter (e.g. Stryd).

    When I first wrote my data field that supports power sensors using the above code, I didn't own a Stryd. So I tested the ANT+ code by connecting it to my Garmin footpod (and I changed some code so that it would read cadence instead of power).

  • Ouch, with uploading it to the watch, it would be terribly difficult to debug.

    Maybe it will interest you, here (developer.garmin.com/.../) they say:
    "The simulator can simulate sensor data via the Simulation menu by selecting Fit Data > Simulate Data. This generates valid but random values that can be read in via the sensor interface. For more acuate simulation, the simulator can play back a FIT file and feed the input into the Sensor module. To do this, select Simulation > Fit Data > Playback File and choose a FIT file from the dialog."

    Maybe it is possible and I'm doing something wrong?

    Edit: I guess it is related to SensorInfo - maybe constricted to only Garmin ANT+ sensors?

  • Background processes run at most every 30 seconds, and I assume you want to update power every second.

    Actually, they run at most every 5 minutes, but can run up to 30 seconds each time

  • Actually, they run at most every 5 minutes, but can run up to 30 seconds each time

    Sorry, by "most" I meant "highest frequency", not "longest period of time". Apologies for the ambiguity and thanks for the additional information (which I was aware of but chose not to share, as I think the minimum period of 30 seconds is already too slow for this use case).

    I'll also note that you used "at most" and "up to" in the above sentence in opposite ways ("at most every 5 minutes" and "up to 30 seconds") even though many would say the phrases "at most" and "up to" are synonymous, which suggests that context matters here. I think many people would also agree that sans context, there's little difference between "at most every 30 seconds" (what I said) and "up to 30 seconds" (part of what you said). In context, what you said is def less ambiguous.

    Next time I'll choose my words more carefully.

  • As you may have noted, the code above uses Ant.GenericChannel, not Sensor. As noted in the quoted docs below, Sensor only works with sensors that are *natively* supported by the device, which again is obviously the opposite of your use case (reading power with a 245).

    Ouch, with uploading it to the watch, it would be terribly difficult to debug.

    I agree. You could buy an ANT+ USB stick and test it that way.

    https://forums.garmin.com/developer/connect-iq/f/discussion/1449/testing-ant-through-the-simulator

    Note that nobody in that thread suggests an alternative other than testing on the watch or using an ANT+ stick.

    developer.garmin.com/.../Sensor.html

    Sensor allows Apps to register for updates to the current sensor data. It also enables apps to control the ANT+ sensors supported natively by the device, which are described by the provided SENSOR_* constants.

    So that may be simulating *sensor data* (through the API that handles sensors connected natively to the device), but it is not simulating a *generic ANT+ sensor* (complete with ANT+ messages).

    It's analogous to how ActivityInfo has sensor data, but not raw ANT+ messages.

  • I have managed to get it working :) (what helped was a tip about debugging through System.println() to .txt file)

    I think now I encounter the problem you mentioned - that the channel suddenly and randomly closes.

    I tried adding this code to the "compute" method of the DataField instance, but it doesn't help.
    Could you direct me where to put it?

    Btw. I have changed "hidden var pSensor" to "var pSensor" in order to be able to access it from another function. Do you think that ithis won't this mess things up?

               if (Application.getApp().pSensor.reopen == true) {
    	        	Application.getApp().pSensor.close();
    	        	try {
    		            //Create the sensor object and open it
    		            Application.getApp().pSensor = new PowerSensor(43121);
    		            Application.getApp().pSensor.open();
    		        } catch(e instanceof Ant.UnableToAcquireChannelException) {
    		            System.println(e.getErrorMessage());
    		            Application.getApp().pSensor = null;
    	        	}
            	}

  • Btw. I have changed "hidden var pSensor" to "var pSensor" in order to be able to access it from another function. Do you think that ithis won't this mess things up?

    It's fine, except I would just pass in "pSensor" to your datafield view so you always have a reference, instead of calling Application.getApp().pSensor all the time. Not only would it be easier to read, but it would save memory, which is important for memory-constrained data fields IMO. (I guess it depends what else you want to do with the field -- e.g. my field is a full-screen complex data field which handles it own rendering so it can draw 6 fields, so it comes pretty close to the 32 KB limit.)

    What I do (although it's a bit clumsy) is to simply have two references: one in in the app instance and one in the data field view instance. When reopen is true, I set both instances to null. (I know it's not great design, but I'm all about saving that memory.)

    I do check reopen in compute(), but the difference is I don't call close() and I don't call open(). I just set both instances to null, then I create a new object. (The constructor should call open() in any case, and the channel was already closed.) Having said that, I'm not sure why your code is failing.

    This workaround was actually shared with me by the folks at Stryd -- they used to solve the disconnection issue in their own app.

    An alternative to keeping 2 references to the sensor would be to just call getApp() once at the top of compute() and reuse that reference.