// api_v1/scanner/parsers/parseFit.js const { execFile } = require("child_process"); // 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 buildTrackPreviewFromFitRecords(records, maxPoints = 300) { if (!Array.isArray(records)) return []; const points = []; for (const r of records) { const f = r.fields || {}; const rawLat = f.position_lat ?? f.latitude ?? f.lat; const rawLng = f.position_long ?? f.position_lon ?? f.longitude ?? f.lng ?? f.lon; const lat = fitSemicirclesToDegrees(rawLat); const lng = fitSemicirclesToDegrees(rawLng); const altRaw = f.altitude ?? f.enhanced_altitude; const alt = altRaw != null ? Number(altRaw) : null; if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; const point = { lat, lng }; if (Number.isFinite(alt)) { point.alt = alt; } points.push(point); } if (points.length <= maxPoints) return points; const step = Math.ceil(points.length / maxPoints); const preview = []; for (let i = 0; i < points.length; i += step) { preview.push(points[i]); } const last = points[points.length - 1]; const currentLast = preview[preview.length - 1]; if ( !currentLast || currentLast.lat !== last.lat || currentLast.lng !== last.lng ) { preview.push(last); } return preview; } async function parseFit(absPath) { const frames = await 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 }, (err, stdout) => { if (err) return reject(err); try { resolve(JSON.parse(stdout)); } catch (e) { reject(new Error("Errore nel parsing JSON FIT: " + e.message)); } } ); }); // ------------------------------- // 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, }, }; } module.exports = parseFit;