├── Dynastream.Fit.Portable.dll ├── local.settings.json ├── host.json ├── Program.cs ├── test.py ├── GetFIT.csproj ├── README.md ├── .gitignore ├── GetFitFromJson.cs └── thttp.py /Dynastream.Fit.Portable.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/GetFIT/main/Dynastream.Fit.Portable.dll -------------------------------------------------------------------------------- /local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "UseDevelopmentStorage=true", 5 | "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated" 6 | } 7 | } -------------------------------------------------------------------------------- /host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.Hosting; 4 | using Microsoft.Azure.Functions.Worker.Configuration; 5 | 6 | namespace GetFIT 7 | { 8 | public class Program 9 | { 10 | public static void Main() 11 | { 12 | var host = new HostBuilder() 13 | .ConfigureFunctionsWorkerDefaults() 14 | .Build(); 15 | 16 | host.Run(); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from thttp import request 2 | 3 | 4 | GETFIT_URL = "https://getfitfile.azurewebsites.net/api/getfitfromjson" 5 | # GETFIT_URL = "http://localhost:7071/api/getfitfromjson" 6 | 7 | 8 | def get_fit(): 9 | response = request(GETFIT_URL, json={ 10 | 'name': 'Test Workout', 11 | 'steps': [{ 12 | "intensity": "interval", 13 | "duration": 60000, 14 | "targetSpeedLow": None, 15 | "targetSpeedHigh": None 16 | }, { 17 | "intensity": "active", 18 | "distance": 1000, 19 | "targetSpeenLow": 4.0, 20 | "targetSpeedHigh": 4.33 21 | }, { 22 | "intensity": "active", 23 | "duration": 1000, 24 | "targetSpeenLow": 3.3, 25 | "targetSpeedHigh": 4.8 26 | }] 27 | }, method='post') 28 | 29 | assert response.status == 200 30 | print(response.headers) 31 | 32 | with open("fartlek.fit", "wb") as f: 33 | f.write(response.content) 34 | 35 | if __name__ == "__main__": 36 | get_fit() 37 | -------------------------------------------------------------------------------- /GetFIT.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0 4 | v4 5 | Exe 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | \Dynastream.Fit.Portable.dll 18 | 19 | 20 | 21 | 22 | PreserveNewest 23 | 24 | 25 | PreserveNewest 26 | Never 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Generate Garmin-compatible FIT Workout files from a simple JSON workout structure. 2 | 3 | Example JSON for the `/api/getfitfromjson` endpoint: 4 | 5 | ```json 6 | { 7 | "name": "GetFIT Test", 8 | "steps": [ 9 | { 10 | "intensity": "warmup", 11 | "duration": 480, 12 | "targetSpeedLow": null, 13 | "targetSpeedHigh": null 14 | }, 15 | { 16 | "intensity": "interval", 17 | "duration": 120, 18 | "targetSpeedLow": 4.0, 19 | "targetSpeedHigh": 4.67 20 | }, 21 | { 22 | "intensity": "recovery", 23 | "duration": 180, 24 | "targetSpeedLow": null, 25 | "targetSpeedHigh": null 26 | }, 27 | { 28 | "intensity": "interval", 29 | "duration": 120, 30 | "targetSpeedLow": 4.0, 31 | "targetSpeedHigh": 4.67 32 | }, 33 | { 34 | "intensity": "recovery", 35 | "duration": 150, 36 | "targetSpeedLow": null, 37 | "targetSpeedHigh": null 38 | }, 39 | { 40 | "intensity": "interval", 41 | "duration": 75, 42 | "targetSpeedLow": 4.0, 43 | "targetSpeedHigh": 4.67 44 | }, 45 | { 46 | "intensity": "recovery", 47 | "duration": 135, 48 | "targetSpeedLow": null, 49 | "targetSpeedHigh": null 50 | }, 51 | { 52 | "intensity": "interval", 53 | "duration": 90, 54 | "targetSpeedLow": 4.0, 55 | "targetSpeedHigh": 4.67 56 | }, 57 | { 58 | "intensity": "recovery", 59 | "duration": 210, 60 | "targetSpeedLow": null, 61 | "targetSpeedHigh": null 62 | }, 63 | { 64 | "intensity": "cooldown", 65 | "duration": 240, 66 | "targetSpeedLow": null, 67 | "targetSpeedHigh": null 68 | } 69 | ] 70 | } 71 | ``` 72 | ## Run Locally 73 | 74 | ``` 75 | func start 76 | ``` 77 | 78 | ## Publish 79 | 80 | ``` 81 | func azure functionapp publish GetFitFile 82 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.sln.docstates 8 | 9 | # Build results 10 | 11 | [Dd]ebug/ 12 | [Rr]elease/ 13 | x64/ 14 | [Bb]in/ 15 | [Oo]bj/ 16 | 17 | # MSTest test Results 18 | [Tt]est[Rr]esult*/ 19 | [Bb]uild[Ll]og.* 20 | 21 | *_i.c 22 | *_p.c 23 | *_i.h 24 | *.ilk 25 | *.meta 26 | *.obj 27 | *.pch 28 | *.pdb 29 | *.pgc 30 | *.pgd 31 | *.rsp 32 | *.sbr 33 | *.tlb 34 | *.tli 35 | *.tlh 36 | *.tmp 37 | *.tmp_proj 38 | *.log 39 | *.vspscc 40 | *.vssscc 41 | .builds 42 | *.pidb 43 | *.log 44 | *.svclog 45 | *.scc 46 | 47 | # Visual C++ cache files 48 | ipch/ 49 | *.aps 50 | *.ncb 51 | *.opensdf 52 | *.sdf 53 | *.cachefile 54 | 55 | # Visual Studio profiler 56 | *.psess 57 | *.vsp 58 | *.vspx 59 | 60 | # Guidance Automation Toolkit 61 | *.gpState 62 | 63 | # ReSharper is a .NET coding add-in 64 | _ReSharper*/ 65 | *.[Rr]e[Ss]harper 66 | *.DotSettings.user 67 | 68 | # Click-Once directory 69 | publish/ 70 | 71 | # Publish Web Output 72 | *.Publish.xml 73 | *.pubxml 74 | *.azurePubxml 75 | 76 | # NuGet Packages Directory 77 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 78 | packages/ 79 | ## TODO: If the tool you use requires repositories.config, also uncomment the next line 80 | !packages/repositories.config 81 | 82 | # Windows Azure Build Output 83 | csx/ 84 | *.build.csdef 85 | 86 | # Windows Store app package directory 87 | AppPackages/ 88 | 89 | # Others 90 | sql/ 91 | *.Cache 92 | ClientBin/ 93 | [Ss]tyle[Cc]op.* 94 | ![Ss]tyle[Cc]op.targets 95 | ~$* 96 | *~ 97 | *.dbmdl 98 | *.[Pp]ublish.xml 99 | 100 | *.publishsettings 101 | 102 | # RIA/Silverlight projects 103 | Generated_Code/ 104 | 105 | # Backup & report files from converting an old project file to a newer 106 | # Visual Studio version. Backup files are not needed, because we have git ;-) 107 | _UpgradeReport_Files/ 108 | Backup*/ 109 | UpgradeLog*.XML 110 | UpgradeLog*.htm 111 | 112 | # SQL Server files 113 | App_Data/*.mdf 114 | App_Data/*.ldf 115 | 116 | # ========================= 117 | # Windows detritus 118 | # ========================= 119 | 120 | # Windows image file caches 121 | Thumbs.db 122 | ehthumbs.db 123 | 124 | # Folder config file 125 | Desktop.ini 126 | 127 | # Recycle Bin used on file shares 128 | $RECYCLE.BIN/ 129 | 130 | # Mac desktop service store files 131 | .DS_Store 132 | 133 | _NCrunch* 134 | -------------------------------------------------------------------------------- /GetFitFromJson.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Net; 3 | using Microsoft.Azure.Functions.Worker; 4 | using Microsoft.Azure.Functions.Worker.Http; 5 | using Microsoft.Extensions.Logging; 6 | using Dynastream.Fit; 7 | using DateTime = Dynastream.Fit.DateTime; 8 | using System.IO; 9 | using Microsoft.AspNetCore.Mvc; 10 | using System.Text; 11 | using System.Threading.Tasks; 12 | using System; 13 | 14 | namespace GetFIT 15 | { 16 | public class FitRequestStep 17 | { 18 | public string intensity { get; set; } 19 | public Nullable duration { get; set; } 20 | public Nullable distance { get; set; } 21 | public Nullable targetSpeedLow { get; set; } 22 | public Nullable targetSpeedHigh { get; set; } 23 | } 24 | 25 | public class FitRequest 26 | { 27 | public string name { get; set; } 28 | public FitRequestStep[] steps { get; set; } 29 | } 30 | 31 | public class GetFitFromJson 32 | { 33 | private readonly ILogger _logger; 34 | 35 | public GetFitFromJson(ILoggerFactory loggerFactory) 36 | { 37 | _logger = loggerFactory.CreateLogger(); 38 | } 39 | 40 | [Function("GetFitFromJson")] 41 | public async Task RunAsync([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) 42 | { 43 | _logger.LogInformation("C# HTTP trigger function processed a request."); 44 | 45 | FitRequest requestData = await req.ReadFromJsonAsync(); 46 | 47 | // 1. Create the output stream, this can be any type of stream, including a file or memory stream. Must have read/write access. 48 | MemoryStream fitDest = new MemoryStream(); 49 | 50 | // 2. Create a FIT Encode object. 51 | Encode encoder = new Encode(ProtocolVersion.V10); 52 | 53 | // 3. Write the FIT header to the output stream. 54 | encoder.Open(fitDest); 55 | 56 | // The timestamp for the workout file 57 | var timeCreated = new Dynastream.Fit.DateTime(System.DateTime.UtcNow); 58 | 59 | // 4. Every FIT file MUST contain a File ID message as the first message 60 | var fileIdMesg = new FileIdMesg(); 61 | fileIdMesg.SetType(Dynastream.Fit.File.Workout); 62 | fileIdMesg.SetManufacturer(Manufacturer.Development); 63 | fileIdMesg.SetProduct(1); 64 | fileIdMesg.SetSerialNumber(timeCreated.GetTimeStamp()); 65 | fileIdMesg.SetTimeCreated(timeCreated); 66 | encoder.Write(fileIdMesg); 67 | 68 | // 5. Every FIT Workout file MUST contain a Workout message as the second message 69 | var workoutMesg = new WorkoutMesg(); 70 | workoutMesg.SetWktName(requestData.name); 71 | workoutMesg.SetSport(Sport.Running); 72 | workoutMesg.SetSubSport(SubSport.Invalid); 73 | workoutMesg.SetNumValidSteps((ushort?)requestData.steps.Length); 74 | encoder.Write(workoutMesg); 75 | 76 | // 6. Every FIT Workout file MUST contain one or more Workout Step messages 77 | ushort stepIndex = 0; 78 | foreach (FitRequestStep step in requestData.steps) { 79 | var workoutStepMesg = new WorkoutStepMesg(); 80 | workoutStepMesg.SetMessageIndex(stepIndex); 81 | 82 | if (step.duration != null) { 83 | workoutStepMesg.SetDurationType(WktStepDuration.Time); // seconds 84 | workoutStepMesg.SetDurationTime(step.duration); 85 | } else { 86 | workoutStepMesg.SetDurationType(WktStepDuration.Distance); 87 | workoutStepMesg.SetDurationDistance(step.distance); 88 | } 89 | 90 | // switching for the different intensities 91 | if (step.intensity == "warmup") { 92 | workoutStepMesg.SetIntensity(Intensity.Warmup); 93 | } else if (step.intensity == "cooldown") { 94 | workoutStepMesg.SetIntensity(Intensity.Cooldown); 95 | } else if (step.intensity == "recovery") { 96 | workoutStepMesg.SetIntensity(Intensity.Recovery); 97 | } else if (step.intensity == "interval") { 98 | workoutStepMesg.SetIntensity(Intensity.Interval); 99 | } else { 100 | workoutStepMesg.SetIntensity(Intensity.Active); 101 | } 102 | 103 | // switching for which type of target 104 | if (step.targetSpeedLow != null) { 105 | workoutStepMesg.SetTargetValue(0); // seems to be required 106 | workoutStepMesg.SetTargetType(WktStepTarget.Speed); 107 | workoutStepMesg.SetCustomTargetSpeedLow(step.targetSpeedLow); // m/s 108 | workoutStepMesg.SetCustomTargetSpeedHigh(step.targetSpeedHigh); // m/s 109 | } else { 110 | workoutStepMesg.SetTargetValue(0); 111 | workoutStepMesg.SetTargetType(WktStepTarget.Open); 112 | } 113 | 114 | Console.WriteLine($"{stepIndex}: {workoutStepMesg.GetIntensity()}"); 115 | encoder.Write(workoutStepMesg); 116 | stepIndex++; 117 | } 118 | 119 | // 7. Update the data size in the header and calculate the CRC 120 | encoder.Close(); 121 | 122 | // // 8. Close the output stream 123 | // fitDest.Close(); 124 | 125 | var response = req.CreateResponse(HttpStatusCode.OK); 126 | response.Headers.Add("Content-Type", "application/octet-stream"); 127 | response.Headers.Add("Content-Disposition", "attachment; filename=\"fartlek.fit\""); 128 | response.WriteBytes(fitDest.ToArray()); 129 | return response; 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /thttp.py: -------------------------------------------------------------------------------- 1 | """ 2 | UNLICENSED 3 | This is free and unencumbered software released into the public domain. 4 | 5 | https://github.com/sesh/thttp 6 | """ 7 | 8 | import gzip 9 | import ssl 10 | import json as json_lib 11 | 12 | from base64 import b64encode 13 | from collections import namedtuple 14 | 15 | from http.cookiejar import CookieJar 16 | from urllib.error import HTTPError, URLError 17 | from urllib.parse import urlencode 18 | from urllib.request import ( 19 | Request, 20 | build_opener, 21 | HTTPRedirectHandler, 22 | HTTPSHandler, 23 | HTTPCookieProcessor, 24 | ) 25 | 26 | 27 | Response = namedtuple("Response", "request content json status url headers cookiejar") 28 | 29 | 30 | class NoRedirect(HTTPRedirectHandler): 31 | def redirect_request(self, req, fp, code, msg, headers, newurl): 32 | return None 33 | 34 | 35 | def request( 36 | url, 37 | params={}, 38 | json=None, 39 | data=None, 40 | headers={}, 41 | method="GET", 42 | verify=True, 43 | redirect=True, 44 | cookiejar=None, 45 | basic_auth=None, 46 | timeout=None, 47 | ): 48 | """ 49 | Returns a (named)tuple with the following properties: 50 | - request 51 | - content 52 | - json (dict; or None) 53 | - headers (dict; all lowercase keys) 54 | - https://stackoverflow.com/questions/5258977/are-http-headers-case-sensitive 55 | - status 56 | - url (final url, after any redirects) 57 | - cookiejar 58 | """ 59 | method = method.upper() 60 | headers = {k.lower(): v for k, v in headers.items()} # lowecase headers 61 | 62 | if params: 63 | url += "?" + urlencode(params) # build URL from params 64 | if json and data: 65 | raise Exception("Cannot provide both json and data parameters") 66 | if method not in ["POST", "PATCH", "PUT"] and (json or data): 67 | raise Exception( 68 | "Request method must POST, PATCH or PUT if json or data is provided" 69 | ) 70 | if not timeout: 71 | timeout = 60 72 | 73 | if json: # if we have json, stringify and put it in our data variable 74 | headers["content-type"] = "application/json" 75 | data = json_lib.dumps(json).encode("utf-8") 76 | elif data: 77 | data = urlencode(data).encode() 78 | 79 | if basic_auth and len(basic_auth) == 2 and "authorization" not in headers: 80 | username, password = basic_auth 81 | headers[ 82 | "authorization" 83 | ] = f'Basic {b64encode(f"{username}:{password}".encode()).decode("ascii")}' 84 | 85 | if not cookiejar: 86 | cookiejar = CookieJar() 87 | 88 | ctx = ssl.create_default_context() 89 | if not verify: # ignore ssl errors 90 | ctx.check_hostname = False 91 | ctx.verify_mode = ssl.CERT_NONE 92 | 93 | handlers = [] 94 | handlers.append(HTTPSHandler(context=ctx)) 95 | handlers.append(HTTPCookieProcessor(cookiejar=cookiejar)) 96 | 97 | if not redirect: 98 | no_redirect = NoRedirect() 99 | handlers.append(no_redirect) 100 | 101 | opener = build_opener(*handlers) 102 | req = Request(url, data=data, headers=headers, method=method) 103 | 104 | try: 105 | with opener.open(req, timeout=timeout) as resp: 106 | status, content, resp_url = (resp.getcode(), resp.read(), resp.geturl()) 107 | headers = {k.lower(): v for k, v in list(resp.info().items())} 108 | 109 | if "gzip" in headers.get("content-encoding", ""): 110 | content = gzip.decompress(content) 111 | 112 | json = ( 113 | json_lib.loads(content) 114 | if "application/json" in headers.get("content-type", "").lower() 115 | and content 116 | else None 117 | ) 118 | except HTTPError as e: 119 | status, content, resp_url = (e.code, e.read(), e.geturl()) 120 | headers = {k.lower(): v for k, v in list(e.headers.items())} 121 | 122 | if "gzip" in headers.get("content-encoding", ""): 123 | content = gzip.decompress(content) 124 | 125 | json = ( 126 | json_lib.loads(content) 127 | if "application/json" in headers.get("content-type", "").lower() and content 128 | else None 129 | ) 130 | 131 | return Response(req, content, json, status, resp_url, headers, cookiejar) 132 | 133 | 134 | import unittest 135 | 136 | 137 | class RequestTestCase(unittest.TestCase): 138 | def test_cannot_provide_json_and_data(self): 139 | with self.assertRaises(Exception): 140 | request( 141 | "https://httpbingo.org/post", 142 | json={"name": "Brenton"}, 143 | data="This is some form data", 144 | ) 145 | 146 | def test_should_fail_if_json_or_data_and_not_p_method(self): 147 | with self.assertRaises(Exception): 148 | request("https://httpbingo.org/post", json={"name": "Brenton"}) 149 | 150 | with self.assertRaises(Exception): 151 | request( 152 | "https://httpbingo.org/post", json={"name": "Brenton"}, method="HEAD" 153 | ) 154 | 155 | def test_should_set_content_type_for_json_request(self): 156 | response = request( 157 | "https://httpbingo.org/post", json={"name": "Brenton"}, method="POST" 158 | ) 159 | self.assertEqual(response.request.headers["Content-type"], "application/json") 160 | 161 | def test_should_work(self): 162 | response = request("https://httpbingo.org/get") 163 | self.assertEqual(response.status, 200) 164 | 165 | def test_should_create_url_from_params(self): 166 | response = request( 167 | "https://httpbingo.org/get", 168 | params={"name": "brenton", "library": "tiny-request"}, 169 | ) 170 | self.assertEqual( 171 | response.url, "https://httpbingo.org/get?name=brenton&library=tiny-request" 172 | ) 173 | 174 | def test_should_return_headers(self): 175 | response = request( 176 | "https://httpbingo.org/response-headers", params={"Test-Header": "value"} 177 | ) 178 | self.assertEqual(response.headers["test-header"], "value") 179 | 180 | def test_should_populate_json(self): 181 | response = request("https://httpbingo.org/json") 182 | self.assertTrue("slideshow" in response.json) 183 | 184 | def test_should_return_response_for_404(self): 185 | response = request("https://httpbingo.org/404") 186 | self.assertEqual(response.status, 404) 187 | self.assertTrue("text/plain" in response.headers["content-type"]) 188 | 189 | def test_should_fail_with_bad_ssl(self): 190 | with self.assertRaises(URLError): 191 | response = request("https://expired.badssl.com/") 192 | 193 | def test_should_load_bad_ssl_with_verify_false(self): 194 | response = request("https://expired.badssl.com/", verify=False) 195 | self.assertEqual(response.status, 200) 196 | 197 | def test_should_form_encode_non_json_post_requests(self): 198 | response = request( 199 | "https://httpbingo.org/post", data={"name": "test-user"}, method="POST" 200 | ) 201 | self.assertEqual(response.json["form"]["name"], ["test-user"]) 202 | 203 | def test_should_follow_redirect(self): 204 | response = request( 205 | "https://httpbingo.org/redirect-to", 206 | params={"url": "https://duckduckgo.com/"}, 207 | ) 208 | self.assertEqual(response.url, "https://duckduckgo.com/") 209 | self.assertEqual(response.status, 200) 210 | 211 | def test_should_not_follow_redirect_if_redirect_false(self): 212 | response = request( 213 | "https://httpbingo.org/redirect-to", 214 | params={"url": "https://duckduckgo.com/"}, 215 | redirect=False, 216 | ) 217 | self.assertEqual(response.status, 302) 218 | 219 | def test_cookies(self): 220 | response = request( 221 | "https://httpbingo.org/cookies/set", 222 | params={"cookie": "test"}, 223 | redirect=False, 224 | ) 225 | response = request( 226 | "https://httpbingo.org/cookies", cookiejar=response.cookiejar 227 | ) 228 | self.assertEqual(response.json["cookie"], "test") 229 | 230 | def test_basic_auth(self): 231 | response = request( 232 | "http://httpbingo.org/basic-auth/user/passwd", basic_auth=("user", "passwd") 233 | ) 234 | self.assertEqual(response.json["authorized"], True) 235 | 236 | def test_should_handle_gzip(self): 237 | response = request( 238 | "http://httpbingo.org/gzip", headers={"Accept-Encoding": "gzip"} 239 | ) 240 | self.assertEqual(response.json["gzipped"], True) 241 | 242 | def test_should_timeout(self): 243 | import socket 244 | with self.assertRaises((TimeoutError, socket.timeout)): 245 | response = request("http://httpbingo.org/delay/3", timeout=1) 246 | 247 | def test_should_handle_head_requests(self): 248 | response = request("http://httpbingo.org/head", method="HEAD") 249 | self.assertTrue(response.content == b"") 250 | --------------------------------------------------------------------------------