408 lines
8.7 KiB
JavaScript
408 lines
8.7 KiB
JavaScript
// api_v1/scanner/parsers/parseFit.js
|
|
const { execFile } = require("child_process");
|
|
|
|
function readFitFrames(absPath) {
|
|
return new Promise(
|
|
(resolve, reject) => {
|
|
execFile(
|
|
"/home/orangepi/.local/pipx/venvs/fitdecode/bin/python",
|
|
[
|
|
"api_v1/scanner/parsers/parse_fit.py",
|
|
absPath,
|
|
],
|
|
{
|
|
maxBuffer:
|
|
1024 * 1024 * 50,
|
|
},
|
|
(error, stdout) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
resolve(
|
|
JSON.parse(stdout),
|
|
);
|
|
} catch (parseError) {
|
|
reject(
|
|
new Error(
|
|
"Errore nel parsing JSON FIT: " +
|
|
parseError.message,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// FIT position_lat / position_long sono in semicircles.
|
|
// Conversione ufficiale: degrees = semicircles * 180 / 2^31
|
|
function fitSemicirclesToDegrees(value) {
|
|
if (value == null) return null;
|
|
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n)) return null;
|
|
|
|
return n * (180 / 2147483648);
|
|
}
|
|
|
|
function buildTrackPointsFromFitRecords(
|
|
records,
|
|
) {
|
|
if (!Array.isArray(records)) {
|
|
return [];
|
|
}
|
|
|
|
const points = [];
|
|
|
|
for (const record of records) {
|
|
const fields =
|
|
record?.fields || {};
|
|
|
|
const rawLat =
|
|
fields.position_lat ??
|
|
fields.latitude ??
|
|
fields.lat;
|
|
|
|
const rawLng =
|
|
fields.position_long ??
|
|
fields.position_lon ??
|
|
fields.longitude ??
|
|
fields.lng ??
|
|
fields.lon;
|
|
|
|
const lat =
|
|
fitSemicirclesToDegrees(
|
|
rawLat,
|
|
);
|
|
|
|
const lng =
|
|
fitSemicirclesToDegrees(
|
|
rawLng,
|
|
);
|
|
|
|
if (
|
|
!Number.isFinite(lat) ||
|
|
!Number.isFinite(lng)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const point = {
|
|
lat,
|
|
lng,
|
|
};
|
|
|
|
const altitudeRaw =
|
|
fields.altitude ??
|
|
fields.enhanced_altitude;
|
|
|
|
const altitude =
|
|
altitudeRaw != null
|
|
? Number(altitudeRaw)
|
|
: null;
|
|
|
|
if (
|
|
Number.isFinite(altitude)
|
|
) {
|
|
point.alt = altitude;
|
|
}
|
|
|
|
points.push(point);
|
|
}
|
|
|
|
return points;
|
|
}
|
|
|
|
function buildTrackPreviewFromFitRecords(
|
|
records,
|
|
maxPoints = 300,
|
|
) {
|
|
const points =
|
|
buildTrackPointsFromFitRecords(
|
|
records,
|
|
);
|
|
|
|
if (
|
|
points.length <= maxPoints
|
|
) {
|
|
return points;
|
|
}
|
|
|
|
const step =
|
|
Math.ceil(
|
|
points.length / maxPoints,
|
|
);
|
|
|
|
const preview = [];
|
|
|
|
for (
|
|
let index = 0;
|
|
index < points.length;
|
|
index += step
|
|
) {
|
|
preview.push(
|
|
points[index],
|
|
);
|
|
}
|
|
|
|
const last =
|
|
points.at(-1);
|
|
|
|
const previewLast =
|
|
preview.at(-1);
|
|
|
|
if (
|
|
last &&
|
|
(
|
|
!previewLast ||
|
|
previewLast.lat !==
|
|
last.lat ||
|
|
previewLast.lng !==
|
|
last.lng
|
|
)
|
|
) {
|
|
preview.push(last);
|
|
}
|
|
|
|
return preview;
|
|
}
|
|
|
|
async function parseFit(absPath) {
|
|
const frames =
|
|
await readFitFrames(
|
|
absPath,
|
|
);
|
|
|
|
// -------------------------------
|
|
// 1. Trova i frame principali
|
|
// -------------------------------
|
|
const sessions = frames.filter(f => f.name === "session");
|
|
const laps = frames.filter(f => f.name === "lap");
|
|
const records = frames.filter(f => f.name === "record");
|
|
const fileId = frames.find(f => f.name === "file_id");
|
|
|
|
const session = sessions[0]?.fields || {};
|
|
const file = fileId?.fields || {};
|
|
|
|
// -------------------------------
|
|
// 2. Tempi
|
|
// -------------------------------
|
|
const startTime = session.start_time || session.start_time_local || null;
|
|
|
|
const endTime = session.total_timer_time && startTime
|
|
? new Date(new Date(startTime).getTime() + session.total_timer_time * 1000)
|
|
: null;
|
|
|
|
const startMillis = startTime ? new Date(startTime).getTime() : null;
|
|
const endMillis = endTime ? endTime.getTime() : null;
|
|
|
|
const durationMillis = session.total_timer_time
|
|
? session.total_timer_time * 1000
|
|
: endMillis && startMillis
|
|
? endMillis - startMillis
|
|
: null;
|
|
|
|
// -------------------------------
|
|
// 3. Distanza / Elevazione
|
|
// -------------------------------
|
|
const distance = session.total_distance || null;
|
|
const elevGain = session.total_ascent || null;
|
|
const elevLoss = session.total_descent || null;
|
|
|
|
// -------------------------------
|
|
// 4. HR / Cadenza / Velocità
|
|
// -------------------------------
|
|
const avgHr = session.avg_heart_rate || null;
|
|
const maxHr = session.max_heart_rate || null;
|
|
|
|
const avgCad = session.avg_cadence || null;
|
|
const maxCad = session.max_cadence || null;
|
|
|
|
const avgSpeed = session.avg_speed || null;
|
|
const maxSpeed = session.max_speed || null;
|
|
|
|
// -------------------------------
|
|
// 5. Bounding box GPS
|
|
// -------------------------------
|
|
let minLat = null;
|
|
let maxLat = null;
|
|
let minLon = null;
|
|
let maxLon = null;
|
|
let minElev = null;
|
|
let maxElev = null;
|
|
|
|
for (const r of records) {
|
|
const f = r.fields || {};
|
|
|
|
const lat = fitSemicirclesToDegrees(f.position_lat);
|
|
const lon = fitSemicirclesToDegrees(f.position_long);
|
|
|
|
const eleRaw =
|
|
f.altitude ??
|
|
f.enhanced_altitude;
|
|
|
|
const ele = eleRaw != null ? Number(eleRaw) : null;
|
|
|
|
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
minLat = minLat == null ? lat : Math.min(minLat, lat);
|
|
maxLat = maxLat == null ? lat : Math.max(maxLat, lat);
|
|
minLon = minLon == null ? lon : Math.min(minLon, lon);
|
|
maxLon = maxLon == null ? lon : Math.max(maxLon, lon);
|
|
}
|
|
|
|
if (Number.isFinite(ele)) {
|
|
minElev = minElev == null ? ele : Math.min(minElev, ele);
|
|
maxElev = maxElev == null ? ele : Math.max(maxElev, ele);
|
|
}
|
|
}
|
|
|
|
const centerLat =
|
|
minLat != null && maxLat != null ? (minLat + maxLat) / 2 : null;
|
|
|
|
const centerLon =
|
|
minLon != null && maxLon != null ? (minLon + maxLon) / 2 : null;
|
|
|
|
// -------------------------------
|
|
// 6. Track preview per gallery
|
|
// -------------------------------
|
|
const trackPreview = buildTrackPreviewFromFitRecords(records, 300);
|
|
|
|
// Log utile temporaneo. Puoi rimuoverlo dopo il test.
|
|
console.log("[parseFit] track preview", {
|
|
file: absPath,
|
|
records: records.length,
|
|
preview: trackPreview.length,
|
|
first: trackPreview[0],
|
|
minLat,
|
|
maxLat,
|
|
minLon,
|
|
maxLon,
|
|
});
|
|
|
|
// -------------------------------
|
|
// 7. Costruzione oggetto attività finale
|
|
// -------------------------------
|
|
return {
|
|
format: "FIT",
|
|
|
|
display_name: session.sport || null,
|
|
route_name: session.sport || null,
|
|
title: session.sport || null,
|
|
description: null,
|
|
notes: null,
|
|
|
|
sport: session.sport || null,
|
|
activity_type: session.sub_sport || null,
|
|
device_manufacturer: file.manufacturer || null,
|
|
device_model: file.product || null,
|
|
source_app: null,
|
|
|
|
start_time_millis: startMillis,
|
|
end_time_millis: endMillis,
|
|
duration_millis: durationMillis,
|
|
|
|
distance_meters: distance,
|
|
elevation_gain_meters: elevGain,
|
|
elevation_loss_meters: elevLoss,
|
|
|
|
min_elevation_meters: minElev,
|
|
max_elevation_meters: maxElev,
|
|
|
|
file_distance_meters: distance,
|
|
file_elevation_gain_meters: elevGain,
|
|
|
|
computed_distance_meters: distance,
|
|
computed_elevation_gain_meters: elevGain,
|
|
|
|
average_speed_mps: avgSpeed,
|
|
max_speed_mps: maxSpeed,
|
|
average_pace_sec_per_km: avgSpeed ? 1000 / avgSpeed : null,
|
|
|
|
average_heart_rate: avgHr,
|
|
max_heart_rate: maxHr,
|
|
average_cadence: avgCad,
|
|
max_cadence: maxCad,
|
|
|
|
calories: session.total_calories || null,
|
|
power_avg: session.avg_power || null,
|
|
power_max: session.max_power || null,
|
|
|
|
point_count: records.length,
|
|
segment_count: laps.length,
|
|
lap_count: laps.length,
|
|
|
|
min_latitude: minLat,
|
|
max_latitude: maxLat,
|
|
min_longitude: minLon,
|
|
max_longitude: maxLon,
|
|
center_latitude: centerLat,
|
|
center_longitude: centerLon,
|
|
|
|
// Preview ridotta del percorso per i thumb della gallery
|
|
track_preview: trackPreview,
|
|
|
|
tags: [],
|
|
|
|
diagnostics: {
|
|
source: "FIT",
|
|
sessions: sessions.length,
|
|
laps: laps.length,
|
|
records: records.length,
|
|
track_preview_points: trackPreview.length,
|
|
coordinates_converted_from_semicircles: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function parseFitTrackPoints(
|
|
absPath,
|
|
) {
|
|
if (!absPath) {
|
|
return [];
|
|
}
|
|
|
|
const frames =
|
|
await readFitFrames(
|
|
absPath,
|
|
);
|
|
|
|
const records =
|
|
frames.filter(
|
|
(frame) =>
|
|
frame?.name ===
|
|
"record",
|
|
);
|
|
|
|
const points =
|
|
buildTrackPointsFromFitRecords(
|
|
records,
|
|
);
|
|
|
|
/*
|
|
console.log(
|
|
"[parseFitTrackPoints] traccia completa",
|
|
{
|
|
file:
|
|
absPath,
|
|
|
|
records:
|
|
records.length,
|
|
|
|
gpsPoints:
|
|
points.length,
|
|
},
|
|
);
|
|
*/
|
|
|
|
return points;
|
|
}
|
|
|
|
module.exports =
|
|
parseFit;
|
|
|
|
module.exports.parseFitTrackPoints =
|
|
parseFitTrackPoints;
|