Support uploading FIT files (and zipped exports) alongside GPX
Maptool::Fit reads Garmin's binary FIT format directly rather than adding a gem — the one Ruby option depends on activesupport, unmaintained since 2022, for a single-purpose binary parser. Only "record" messages are read (position + altitude; everything else is skipped by byte length, never interpreted); field numbers and scale/offset are Garmin's own (fit-python-sdk's profile.py), and the framing plus all extracted values were checked byte-for-byte against 5 real device files from python-fitparse's test suite before this shipped. A .zip upload (Garmin's bulk/FIT export format) is expanded into its .gpx/.fit entries via rubyzip — that one's an actual dependency, since ZIP's own format isn't the kind of narrow, single-purpose parsing FIT above is.
This commit is contained in:
1 parent
8d3c126a14
commit
fc174d4267
8 files changed
+338
-29
No files matched your search
@@ -0,0 +1,232 @@
|
||||
module Maptool
|
||||
# Converts a Garmin FIT file (binary — see https://developer.garmin.com/fit/)
|
||||
# to a GeoJSON FeatureCollection, in the same shape Gpx.to_geojson produces
|
||||
# for a <trk>: one Feature per file, geometry MultiLineString, "kind" =>
|
||||
# "track". Only positioned "record" messages are read — FIT also carries
|
||||
# laps/sessions/events/device info/developer fields, none of which this
|
||||
# app has anywhere to show, so they're skipped by byte-length rather than
|
||||
# interpreted.
|
||||
module Fit
|
||||
class ParseError < StandardError; end
|
||||
|
||||
FIT_EPOCH = Time.utc(1989, 12, 31).to_i
|
||||
RECORD_MESSAGE_NUM = 20
|
||||
FIELD_POSITION_LAT = 0
|
||||
FIELD_POSITION_LONG = 1
|
||||
FIELD_ALTITUDE = 2
|
||||
FIELD_ENHANCED_ALTITUDE = 78
|
||||
SEMICIRCLE_TO_DEGREES = 180.0 / (2**31)
|
||||
|
||||
Definition = Struct.new(:global_mesg_num, :little_endian, :fields, :total_size)
|
||||
|
||||
def self.kinds(path)
|
||||
to_geojson(path)["features"].empty? ? [] : ["track"]
|
||||
rescue ParseError
|
||||
[]
|
||||
end
|
||||
|
||||
def self.to_geojson(path)
|
||||
points = parse(File.binread(path))
|
||||
features = points.empty? ? [] : [track_feature(points)]
|
||||
{ "type" => "FeatureCollection", "features" => features }
|
||||
end
|
||||
|
||||
def self.track_feature(points)
|
||||
{
|
||||
"type" => "Feature",
|
||||
"geometry" => { "type" => "MultiLineString", "coordinates" => [points] },
|
||||
"properties" => { "kind" => "track", "name" => nil },
|
||||
}
|
||||
end
|
||||
private_class_method :track_feature
|
||||
|
||||
def self.parse(data)
|
||||
raise ParseError, "not a FIT file" if data.bytesize < 12 || data.byteslice(8, 4) != ".FIT"
|
||||
|
||||
header_size = data.getbyte(0)
|
||||
data_size = data.byteslice(4, 4).unpack1("L<")
|
||||
data_end = header_size + data_size
|
||||
raise ParseError, "truncated file" if data_end > data.bytesize
|
||||
|
||||
offset = header_size
|
||||
definitions = {}
|
||||
points = []
|
||||
|
||||
while offset < data_end
|
||||
header = data.getbyte(offset)
|
||||
offset += 1
|
||||
|
||||
if header & 0x80 != 0
|
||||
local_type = (header >> 5) & 0x3
|
||||
definition = definitions[local_type] or raise ParseError, "data message before its definition"
|
||||
offset = read_data_message(data, offset, definition, points)
|
||||
elsif header & 0x40 != 0
|
||||
local_type = header & 0x0F
|
||||
definition, offset = read_definition(data, offset, header & 0x20 != 0)
|
||||
definitions[local_type] = definition
|
||||
else
|
||||
local_type = header & 0x0F
|
||||
definition = definitions[local_type] or raise ParseError, "data message before its definition"
|
||||
offset = read_data_message(data, offset, definition, points)
|
||||
end
|
||||
end
|
||||
|
||||
points
|
||||
rescue ArgumentError, NoMethodError => e
|
||||
raise ParseError, e.message
|
||||
end
|
||||
private_class_method :parse
|
||||
|
||||
def self.read_definition(data, offset, has_developer_fields)
|
||||
architecture = data.getbyte(offset + 1)
|
||||
little_endian = architecture.zero?
|
||||
global_mesg_num = data.byteslice(offset + 2, 2).unpack1(little_endian ? "S<" : "S>")
|
||||
num_fields = data.getbyte(offset + 4)
|
||||
offset += 5
|
||||
|
||||
fields = []
|
||||
num_fields.times do
|
||||
fields << [data.getbyte(offset), data.getbyte(offset + 1)]
|
||||
offset += 3
|
||||
end
|
||||
|
||||
if has_developer_fields
|
||||
num_dev_fields = data.getbyte(offset)
|
||||
offset += 1
|
||||
num_dev_fields.times do
|
||||
fields << [nil, data.getbyte(offset + 1)]
|
||||
offset += 3
|
||||
end
|
||||
end
|
||||
|
||||
total_size = fields.sum { |_field_num, size| size }
|
||||
[Definition.new(global_mesg_num, little_endian, fields, total_size), offset]
|
||||
end
|
||||
private_class_method :read_definition
|
||||
|
||||
def self.read_data_message(data, offset, definition, points)
|
||||
if definition.global_mesg_num == RECORD_MESSAGE_NUM
|
||||
point = extract_point(data, offset, definition)
|
||||
points << point if point
|
||||
end
|
||||
offset + definition.total_size
|
||||
end
|
||||
private_class_method :read_data_message
|
||||
|
||||
# Only reads the 3 fields the map needs; every other field in the
|
||||
# message (heart rate, cadence, power, ...) is walked past via its
|
||||
# declared size without being interpreted.
|
||||
def self.extract_point(data, offset, definition)
|
||||
lat = lon = altitude = nil
|
||||
pos = offset
|
||||
|
||||
definition.fields.each do |field_num, size|
|
||||
case field_num
|
||||
when FIELD_POSITION_LAT
|
||||
lat = unpack_int(data, pos, size, definition.little_endian, signed: true)
|
||||
when FIELD_POSITION_LONG
|
||||
lon = unpack_int(data, pos, size, definition.little_endian, signed: true)
|
||||
when FIELD_ALTITUDE, FIELD_ENHANCED_ALTITUDE
|
||||
altitude ||= unpack_int(data, pos, size, definition.little_endian, signed: false)
|
||||
end
|
||||
pos += size
|
||||
end
|
||||
|
||||
return nil if lat.nil? || lon.nil? || invalid?(lat, 4) || invalid?(lon, 4)
|
||||
|
||||
coordinate = [lon * SEMICIRCLE_TO_DEGREES, lat * SEMICIRCLE_TO_DEGREES]
|
||||
coordinate << (altitude / 5.0 - 500) if altitude && !invalid?(altitude, 2) && !invalid?(altitude, 4)
|
||||
coordinate
|
||||
end
|
||||
private_class_method :extract_point
|
||||
|
||||
UNPACK_FORMATS = {
|
||||
[1, true] => "c", [1, false] => "C",
|
||||
[2, true] => "s", [2, false] => "S",
|
||||
[4, true] => "l", [4, false] => "L",
|
||||
[8, true] => "q", [8, false] => "Q",
|
||||
}.freeze
|
||||
|
||||
def self.unpack_int(data, offset, size, little_endian, signed:)
|
||||
format = UNPACK_FORMATS[[size, signed]]
|
||||
return nil unless format
|
||||
|
||||
format += little_endian ? "<" : ">" if size > 1
|
||||
data.byteslice(offset, size)&.unpack1(format)
|
||||
end
|
||||
private_class_method :unpack_int
|
||||
|
||||
# FIT's per-base-type "field not present" sentinel: all bits set for an
|
||||
# unsigned/positive-only reading, the same pattern for signed. Good
|
||||
# enough for the two fields this cares about (position, altitude)
|
||||
# without needing every base type's exact invalid value.
|
||||
def self.invalid?(value, size)
|
||||
value == (1 << (size * 8 - 1)) - 1 || value == -1 || value == (1 << (size * 8)) - 1
|
||||
end
|
||||
private_class_method :invalid?
|
||||
end
|
||||
end
|
||||
|
||||
if __FILE__ == $0
|
||||
require "tmpdir"
|
||||
|
||||
# A hand-built minimal FIT file: header + one definition message (local
|
||||
# type 0, global mesg 20 "record", fields position_lat/position_long,
|
||||
# both sint32) + two data messages + a throwaway CRC. Exercises the
|
||||
# header/definition/data framing without external fixtures.
|
||||
def le32(n)
|
||||
[n].pack("l<")
|
||||
end
|
||||
|
||||
def build_fit(records)
|
||||
definition = [0, 0].pack("CC") + [20].pack("S<") + [2].pack("C") +
|
||||
[0, 4, 0x85].pack("C3") + [1, 4, 0x85].pack("C3")
|
||||
definition_header = [0b01000000].pack("C")
|
||||
|
||||
data = records.map do |lat, lon|
|
||||
[0b00000000].pack("C") + le32(lat) + le32(lon)
|
||||
end.join
|
||||
|
||||
body = definition_header + definition + data
|
||||
header = [12, 16, 0, 0].pack("C2S<") + le32(body.bytesize) + ".FIT"
|
||||
header + body + "\x00\x00"
|
||||
end
|
||||
|
||||
Dir.mktmpdir do |dir|
|
||||
path = File.join(dir, "test.fit")
|
||||
File.binwrite(path, build_fit([[100_000_000, 200_000_000], [100_000_100, 200_000_100]]))
|
||||
|
||||
geojson = Maptool::Fit.to_geojson(path)
|
||||
coords = geojson["features"][0]["geometry"]["coordinates"][0]
|
||||
raise "expected 2 points" unless coords.size == 2
|
||||
expected_lat = 100_000_000 * Maptool::Fit::SEMICIRCLE_TO_DEGREES
|
||||
expected_lon = 200_000_000 * Maptool::Fit::SEMICIRCLE_TO_DEGREES
|
||||
raise "bad coordinate: #{coords[0]}" unless (coords[0][1] - expected_lat).abs < 1e-9 && (coords[0][0] - expected_lon).abs < 1e-9
|
||||
raise "expected kind track" unless Maptool::Fit.kinds(path) == ["track"]
|
||||
|
||||
empty_path = File.join(dir, "empty.fit")
|
||||
File.binwrite(empty_path, build_fit([]))
|
||||
raise "expected no features for a pointless file" unless Maptool::Fit.to_geojson(empty_path)["features"].empty?
|
||||
raise "expected no kinds for a pointless file" unless Maptool::Fit.kinds(empty_path) == []
|
||||
|
||||
raise "expected ParseError for garbage" rescue nil
|
||||
begin
|
||||
Maptool::Fit.to_geojson(File.join(dir, "nonexistent.fit"))
|
||||
raise "expected an error reading a missing file"
|
||||
rescue Errno::ENOENT
|
||||
# fine — this app only ever calls to_geojson with a path it already
|
||||
# knows exists
|
||||
end
|
||||
|
||||
garbage_path = File.join(dir, "garbage.fit")
|
||||
File.binwrite(garbage_path, "not a fit file at all")
|
||||
begin
|
||||
Maptool::Fit.to_geojson(garbage_path)
|
||||
raise "expected ParseError for garbage input"
|
||||
rescue Maptool::Fit::ParseError
|
||||
# expected
|
||||
end
|
||||
end
|
||||
|
||||
puts "ok"
|
||||
end
|
||||
@@ -1,12 +1,17 @@
|
||||
require "fileutils"
|
||||
require_relative "gpx"
|
||||
require_relative "fit"
|
||||
|
||||
module Maptool
|
||||
# Filesystem-backed store for uploaded GPX files, organized in
|
||||
# Filesystem-backed store for uploaded GPX/FIT files, organized in
|
||||
# user-chosen folders (arbitrary nesting). All paths coming from the
|
||||
# outside (folder names, file paths in URLs) are resolved and checked
|
||||
# against the store root before touching the filesystem, so path
|
||||
# traversal (e.g. "../../etc") can't escape it.
|
||||
#
|
||||
# A .zip upload (Garmin exports FIT files zipped) isn't handled here —
|
||||
# the caller (app.rb) unpacks it and calls save() once per extracted
|
||||
# file, same as it would for several files dropped in at once.
|
||||
class GpxStore
|
||||
class InvalidPath < StandardError; end
|
||||
|
||||
@@ -16,12 +21,12 @@ module Maptool
|
||||
end
|
||||
|
||||
# Nested { type:, name:, path:, children: } hash describing every
|
||||
# folder and .gpx file under the store root.
|
||||
# folder and .gpx/.fit file under the store root.
|
||||
def tree
|
||||
{ "type" => "folder", "name" => "root", "path" => "", "children" => list_children(@root, "") }
|
||||
end
|
||||
|
||||
# Saves `io`'s contents as a .gpx file under `folder` (created if
|
||||
# Saves `io`'s contents as a .gpx/.fit file under `folder` (created if
|
||||
# needed), deriving a safe filename from `filename`. Returns the
|
||||
# stored file's path relative to the store root.
|
||||
def save(folder:, filename:, io:)
|
||||
@@ -91,10 +96,12 @@ module Maptool
|
||||
path == @root || path.start_with?(@root + File::SEPARATOR)
|
||||
end
|
||||
|
||||
TRACK_EXTENSIONS = %w[.gpx .fit].freeze
|
||||
|
||||
def safe_filename(filename)
|
||||
base = File.basename(filename.to_s)
|
||||
base = "upload.gpx" if base.empty? || base == "." || base == ".."
|
||||
base = "#{base}.gpx" unless base.downcase.end_with?(".gpx")
|
||||
base = "#{base}.gpx" unless TRACK_EXTENSIONS.any? { |ext| base.downcase.end_with?(ext) }
|
||||
base
|
||||
end
|
||||
|
||||
@@ -119,6 +126,8 @@ module Maptool
|
||||
{ "type" => "folder", "name" => entry, "path" => rel, "children" => list_children(full, rel) }
|
||||
elsif entry.downcase.end_with?(".gpx")
|
||||
{ "type" => "file", "name" => entry, "path" => rel, "kinds" => Gpx.kinds(full) }
|
||||
elsif entry.downcase.end_with?(".fit")
|
||||
{ "type" => "file", "name" => entry, "path" => rel, "kinds" => Fit.kinds(full) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in new issue
Block a user