// 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;