├── 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 |
--------------------------------------------------------------------------------