Skip to content

Commit 82be2cc

Browse files
michallaskowskiMichal Laskowski
authored and
Michal Laskowski
committed
Initial commit
1 parent 733aa51 commit 82be2cc

File tree

7 files changed

+373
-10
lines changed

7 files changed

+373
-10
lines changed

Diff for: Package.resolved

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"object": {
3+
"pins": [
4+
{
5+
"package": "FitFileParser",
6+
"repositoryURL": "https://github.com/roznet/FitFileParser",
7+
"state": {
8+
"branch": null,
9+
"revision": "d693f598db2000359e7cb56bc4219a3806b39dec",
10+
"version": "1.4.1"
11+
}
12+
},
13+
{
14+
"package": "SwiftyXMLParser",
15+
"repositoryURL": "https://github.com/yahoojapan/SwiftyXMLParser",
16+
"state": {
17+
"branch": null,
18+
"revision": "ec7f183642adf429babd867d1a38c5c6912408ba",
19+
"version": "5.3.0"
20+
}
21+
}
22+
]
23+
},
24+
"version": 1
25+
}

Diff for: Package.swift

+25-7
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,40 @@ import PackageDescription
55

66
let package = Package(
77
name: "WorkoutDecoders",
8+
platforms: [.iOS(.v11), .macOS(.v10_14), .watchOS(.v4)],
89
products: [
9-
// Products define the executables and libraries a package produces, and make them visible to other packages.
10+
.library(
11+
name: "ZwoWorkoutDecoder",
12+
targets: ["ZwoWorkoutDecoder"]),
13+
.library(
14+
name: "FitWorkoutDecoder",
15+
targets: ["FitWorkoutDecoder"]),
1016
.library(
1117
name: "WorkoutDecoders",
12-
targets: ["WorkoutDecoders"]),
18+
targets: ["WorkoutDecoders"])
1319
],
1420
dependencies: [
15-
// Dependencies declare other packages that this package depends on.
16-
// .package(url: /* package url */, from: "1.0.0"),
21+
22+
.package(
23+
name: "SwiftyXMLParser",
24+
url: "https://github.com/yahoojapan/SwiftyXMLParser", from: "5.3.0"),
25+
.package(
26+
name: "FitFileParser",
27+
url: "https://github.com/roznet/FitFileParser", from: "1.4.1")
1728
],
1829
targets: [
19-
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
20-
// Targets can depend on other targets in this package, and on products in packages this package depends on.
2130
.target(
22-
name: "WorkoutDecoders",
31+
name: "WorkoutDecoderBase",
2332
dependencies: []),
33+
.target(
34+
name: "ZwoWorkoutDecoder",
35+
dependencies: ["SwiftyXMLParser", "WorkoutDecoderBase"]),
36+
.target(
37+
name: "FitWorkoutDecoder",
38+
dependencies: ["FitFileParser", "WorkoutDecoderBase"]),
39+
.target(
40+
name: "WorkoutDecoders",
41+
dependencies: ["ZwoWorkoutDecoder", "FitWorkoutDecoder"]),
2442
.testTarget(
2543
name: "WorkoutDecodersTests",
2644
dependencies: ["WorkoutDecoders"]),

Diff for: Sources/FitWorkoutDecoder/FitWorkoutDecoder.swift

+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//
2+
// FitWorkoutDecoder.swift
3+
// WatchTrainer
4+
//
5+
// Created by Laskowski, Michal on 14/12/2020.
6+
//
7+
8+
import Foundation
9+
import WorkoutDecoderBase
10+
import FitFileParser
11+
12+
public enum FitDecodeError: Error {
13+
case notOneWorkoutMessage
14+
case notSupportedRange
15+
}
16+
17+
private struct RepeatSteps {
18+
let range: Range<Int>
19+
let repeatCount: Int
20+
}
21+
22+
public final class FitWorkoutDecoder: WorkoutDecoding {
23+
private let userFtp: Int
24+
public init(userFtp: Int) {
25+
self.userFtp = userFtp
26+
}
27+
28+
public func decodeWorkout(from url: URL, data: Data) throws -> Workout {
29+
let file = FitFile(data: data)
30+
31+
let workoutMessages = file.messages(forMessageType: .workout)
32+
guard workoutMessages.count == 1 else {
33+
throw FitDecodeError.notOneWorkoutMessage
34+
}
35+
let workoutName = workoutMessages[0].interpretedField(key: "wkt_name")?.name ?? ""
36+
assert(!workoutName.isEmpty)
37+
38+
let workoutSteps = file.messages(forMessageType: .workout_step)
39+
let repeatSteps = workoutSteps.enumerated()
40+
.filter { step in
41+
step.element.interpretedField(key: "duration_type")?.name == "repeat_until_steps_cmplt"
42+
}
43+
.map { repeatStep -> RepeatSteps in
44+
let startIndex = repeatStep.element.interpretedField(key: "duration_value")?.value.map {
45+
Int($0)
46+
} ?? -1
47+
assert(startIndex >= 0)
48+
let repeatRange = Range<Int>(uncheckedBounds: (lower: startIndex, upper: repeatStep.offset - 1))
49+
let repeatCount = repeatStep.element.interpretedField(key: "target_value")?.value.map {
50+
Int($0)
51+
} ?? -1
52+
assert(repeatCount >= 0)
53+
54+
return RepeatSteps(range: repeatRange, repeatCount: repeatCount)
55+
}
56+
57+
let parts = try repeatSteps.map { repeatStep -> WorkoutPart in
58+
let stepsDistance = repeatStep.range.upperBound - repeatStep.range.lowerBound
59+
switch stepsDistance {
60+
case 0:
61+
let workoutStep = workoutSteps[repeatStep.range.lowerBound]
62+
return WorkoutPart.steady(duration: workoutStep.duration, power: workoutStep.power(for: userFtp))
63+
case 1:
64+
let firstWorkoutStep = workoutSteps[repeatStep.range.lowerBound]
65+
let secondWorkoutStep = workoutSteps[repeatStep.range.upperBound]
66+
return WorkoutPart.intervals(repeat: repeatStep.repeatCount,
67+
onDuration: firstWorkoutStep.duration, onPower: firstWorkoutStep.power(for: userFtp),
68+
offDuration: secondWorkoutStep.duration, offPower: secondWorkoutStep.power(for: userFtp))
69+
default:
70+
throw FitDecodeError.notSupportedRange
71+
}
72+
}
73+
74+
return Workout(name: workoutName,
75+
parts: parts,
76+
messages: [])
77+
}
78+
}
79+
80+
private extension FitFileParser.FitMessage {
81+
var duration: Int {
82+
let duration = interpretedField(key: "duration_value")?.value.map {
83+
Int($0 / 1000.0)
84+
} ?? -1
85+
assert(duration > 0)
86+
return duration
87+
}
88+
89+
func power(for ftp: Int) -> Double {
90+
let power = interpretedField(key: "custom_target_value_high")?.value.map {
91+
$0.fitPowerValueToRelativeFtp(for: ftp)
92+
} ?? -1
93+
assert(power > 0)
94+
return power
95+
}
96+
}
97+
98+
// based on https://developer.garmin.com/fit/cookbook/encoding-workout-files/
99+
// Relative values are provided as an integer value ranging 0 – 1000% functional threshold power (FTP)
100+
// the ranges 0 to 1000 (power) are reserved for relative values
101+
// power values must be offset 1000 watts.
102+
extension Double {
103+
func fitPowerValueToRelativeFtp(for ftp: Int) -> Double {
104+
if self < 1000 {
105+
return self / 100.0
106+
} else {
107+
return (self - 1000.0) / Double(ftp)
108+
}
109+
}
110+
}

Diff for: Sources/WorkoutDecoderBase/WorkoutDecoding.swift

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
//
2+
// WorkoutDecoding.swift
3+
// WatchTrainer
4+
//
5+
// Created by Laskowski, Michal on 14/12/2020.
6+
//
7+
8+
import Foundation
9+
10+
public protocol WorkoutDecoding {
11+
func decodeWorkout(from url: URL, data: Data) throws -> Workout
12+
}
13+
14+
public enum WorkoutPart {
15+
case steady(duration: Int, power: Double)
16+
case intervals(repeat: Int, onDuration: Int, onPower: Double, offDuration: Int, offPower: Double)
17+
case ramp(duration: Int, powerLow: Double, powerHigh: Double)
18+
case freeRide(duration: Int)
19+
20+
public func toSegments(startIndex: Int) -> [WorkoutSegment] {
21+
switch self {
22+
case .steady(let duration, let power):
23+
return [WorkoutSegment(duration: duration, index: startIndex, intervalIndex: nil, powerStart: power, powerEnd: nil)]
24+
case .intervals(let repeats, let onDuration, let onPower, let offDuration, let offPower):
25+
return (0...repeats).map { index -> [WorkoutSegment] in
26+
[
27+
WorkoutSegment(duration: onDuration, index: startIndex + index * 2,
28+
intervalIndex: index, powerStart: onPower, powerEnd: nil),
29+
WorkoutSegment(duration: offDuration, index: startIndex + index * 2 + 1,
30+
intervalIndex: index, powerStart: offPower, powerEnd: nil),
31+
]
32+
}.flatMap { $0 }
33+
34+
case .ramp(let duration, let powerLow, let powerHigh):
35+
return [WorkoutSegment(duration: duration, index: startIndex,
36+
intervalIndex: nil, powerStart: powerLow, powerEnd: powerHigh)]
37+
case .freeRide(let duration):
38+
return [WorkoutSegment(duration: duration, index: startIndex, intervalIndex: nil, powerStart: -1.0, powerEnd: nil)]
39+
}
40+
}
41+
}
42+
43+
public struct WorkoutSegment: Codable {
44+
public let duration: Int
45+
public let index: Int
46+
public let intervalIndex: Int?
47+
public let powerStart: Double // negative power means free ride
48+
public let powerEnd: Double?
49+
50+
public func powerAt(second: Int, for ftp: Double) -> Int {
51+
let power: Double
52+
if let powerOff = powerEnd {
53+
power = Double(second) / Double(duration) * (powerOff - powerStart) + powerStart
54+
} else {
55+
power = powerStart
56+
}
57+
return Int(power * ftp)
58+
}
59+
}
60+
61+
public struct WorkoutMessage: Codable {
62+
public let timeOffset: Int
63+
public let message: String
64+
65+
public init(timeOffset: Int, message: String) {
66+
self.timeOffset = timeOffset
67+
self.message = message
68+
}
69+
}
70+
71+
public struct Workout: Codable {
72+
public let name: String
73+
public let segments: [WorkoutSegment]
74+
public let duration: Int
75+
public let messages: [WorkoutMessage]
76+
77+
public init(name: String, parts: [WorkoutPart], messages: [WorkoutMessage]) {
78+
self.name = name
79+
self.messages = messages
80+
segments = parts.reduce([WorkoutSegment](), { (accumulator, part) -> [WorkoutSegment] in
81+
let index = (accumulator.last?.index ?? -1) + 1
82+
return accumulator + part.toSegments(startIndex: index)
83+
})
84+
duration = segments.reduce(0, { $0 + $1.duration })
85+
}
86+
}

Diff for: Sources/WorkoutDecoders/WorkoutDecoder.swift

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// WorkoutDecoder.swift
3+
// WatchTrainer
4+
//
5+
// Created by Laskowski, Michal on 14/12/2020.
6+
//
7+
8+
import Foundation
9+
import WorkoutDecoderBase
10+
import FitWorkoutDecoder
11+
import ZwoWorkoutDecoder
12+
13+
public struct UnknownFileException: Error {}
14+
15+
final class WorkoutDecoder: WorkoutDecoding {
16+
17+
private let userFtp: Int
18+
private lazy var fitDecoder: FitWorkoutDecoder = {
19+
FitWorkoutDecoder(userFtp: userFtp)
20+
}()
21+
private lazy var zwoDecoder: ZwoWorkoutDecoder = {
22+
ZwoWorkoutDecoder()
23+
}()
24+
25+
init(userFtp: Int) {
26+
self.userFtp = userFtp
27+
}
28+
29+
func decodeWorkout(from url: URL, data: Data) throws -> Workout {
30+
switch url.pathExtension {
31+
case "zwo":
32+
return try zwoDecoder.decodeWorkout(from: url, data: data)
33+
case "fit":
34+
return try fitDecoder.decodeWorkout(from: url, data: data)
35+
default:
36+
throw UnknownFileException()
37+
}
38+
}
39+
}

Diff for: Sources/WorkoutDecoders/WorkoutDecoders.swift

-3
This file was deleted.

0 commit comments

Comments
 (0)