Use speed_enhanced, speed or elevation_enhanced and elevation

This commit is contained in:
Tim Zöller 2026-04-07 22:06:37 +02:00
parent cfea7abff3
commit df3fdad43c
4 changed files with 201 additions and 7 deletions

View file

@ -17,10 +17,13 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.List;
@ -43,6 +46,24 @@ public class SecurityConfig {
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// One-shot migration carve-out: allow POST /api/debug/reprocess-fit-elevation
// ONLY when the request comes from a loopback address. The intended workflow
// is "open a shell on the prod container, curl localhost:8080" anyone outside
// the host network is rejected by the trailing denyAll() on /api/debug/**.
// Remove this matcher (and the corresponding permitAll line below) once the
// FIT elevation backfill is complete.
final RequestMatcher loopbackFitElevationMatcher = request -> {
if (!"POST".equalsIgnoreCase(request.getMethod())
|| !"/api/debug/reprocess-fit-elevation".equals(request.getRequestURI())) {
return false;
}
try {
return InetAddress.getByName(request.getRemoteAddr()).isLoopbackAddress();
} catch (UnknownHostException e) {
return false;
}
};
http
.csrf(csrf -> csrf.disable()) // Disable CSRF for REST API
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
@ -99,7 +120,10 @@ public class SecurityConfig {
// Public endpoints - User's public activities
.requestMatchers(HttpMethod.GET, "/api/activities/user/*").permitAll()
// Debug endpoints (dev only)
// Debug endpoints denied by default. Specific endpoints can be
// carved out above this line for one-off backfills / migrations.
// Re-lock by removing the carve-outs once the migration is done.
.requestMatchers(loopbackFitElevationMatcher).permitAll()
.requestMatchers("/api/debug/**").denyAll()
// Public endpoints - Likes and Comments (GET only)

View file

@ -164,4 +164,63 @@ public class DebugController {
return org.springframework.http.ResponseEntity.ok(Map.of("status", "started"));
}
private final java.util.concurrent.atomic.AtomicBoolean fitElevationReprocessRunning =
new java.util.concurrent.atomic.AtomicBoolean(false);
/**
* Backfill the per-track-point elevation profile for FIT activities that
* currently have none. Re-parses each FIT activity's stored raw bytes through
* the current FitParser (which reads {@code enhanced_altitude} from modern
* Garmin devices in addition to the legacy {@code altitude} field) and
* replaces the activity's track points JSON.
*
* <p>The job is idempotent: activities that already have at least one non-null
* elevation value on a track point are skipped, so re-running the backfill is
* safe and only touches the still-broken activities.
*
* <p>Runs on a single background thread, with progress logged every 100 rows.
* A second concurrent invocation returns {@code "already running"} immediately.
*/
@org.springframework.web.bind.annotation.PostMapping("/reprocess-fit-elevation")
public org.springframework.http.ResponseEntity<Map<String, String>> reprocessFitElevation() {
if (!fitElevationReprocessRunning.compareAndSet(false, true)) {
return org.springframework.http.ResponseEntity.ok(Map.of("status", "already running"));
}
new Thread(() -> {
try {
var ids = activityRepository.findAllIds();
log.info("Starting FIT elevation reprocessing for {} activities", ids.size());
int updated = 0, skipped = 0, failed = 0;
for (int i = 0; i < ids.size(); i++) {
try {
var activity = activityRepository.findById(ids.get(i)).orElse(null);
if (activity == null || !"FIT".equals(activity.getSourceFileFormat())) {
skipped++;
continue;
}
if (activityFileService.reprocessFitElevation(activity)) {
updated++;
} else {
skipped++;
}
} catch (Exception e) {
failed++;
log.warn("Failed to reprocess FIT elevation for {}: {}", ids.get(i), e.getMessage());
}
if ((i + 1) % 100 == 0) {
log.info("FIT elevation reprocess progress: {} / {} (updated={}, skipped={}, failed={})",
i + 1, ids.size(), updated, skipped, failed);
}
}
log.info("FIT elevation reprocessing complete: updated={}, skipped={}, failed={}", updated, skipped, failed);
} finally {
fitElevationReprocessRunning.set(false);
}
}, "fit-elevation-reprocess").start();
return org.springframework.http.ResponseEntity.ok(Map.of("status", "started"));
}
}

View file

@ -232,6 +232,102 @@ public class ActivityFileService {
}
}
/**
* Reprocess elevation for a FIT activity that currently has no per-point
* elevation profile. Re-parses the stored raw FIT bytes with the current parser
* (which reads {@code enhanced_altitude} in addition to the legacy {@code altitude}
* field) and replaces the activity's track-points JSON. Also fills in
* {@code elevationGain}/{@code elevationLoss} if they were missing.
*
* <p>Returns {@code false} (and does not touch the activity) if the activity:
* <ul>
* <li>is not a FIT file,</li>
* <li>has no raw file stored (e.g. uploaded before raw file persistence was added),</li>
* <li>already has a non-null elevation value on at least one track point,</li>
* <li>or fails to re-parse for any reason.</li>
* </ul>
*
* @param activity the activity to reprocess
* @return true if the track points JSON was updated
*/
@Transactional
public boolean reprocessFitElevation(Activity activity) {
if (!"FIT".equals(activity.getSourceFileFormat())) {
return false;
}
byte[] rawFile = activity.getRawActivityFile();
if (rawFile == null || rawFile.length == 0) {
log.debug("No raw file stored for FIT activity {}, skipping", activity.getId());
return false;
}
// Skip activities that already have per-point elevation. This makes the
// backfill idempotent running it twice in a row is safe and the second
// run is a no-op for already-fixed activities.
if (trackJsonAlreadyHasElevation(activity.getTrackPointsJson())) {
return false;
}
try {
ParsedActivityData parsedData = fitParser.parse(rawFile);
// Replace the entire track points blob. The new JSON will contain
// elevation values from enhanced_altitude (or legacy altitude as
// fallback) thanks to the FitParser fix.
String newJson = convertTrackPointsToJson(parsedData.getTrackPoints());
activity.setTrackPointsJson(newJson);
// Backfill session totals if they were missing. We don't overwrite
// existing values those came from session.getTotalAscent() at upload
// time and the device's own calculation is likely more accurate than
// anything we'd recompute from the track points.
if (activity.getElevationGain() == null && parsedData.getElevationGain() != null) {
activity.setElevationGain(parsedData.getElevationGain());
}
if (activity.getElevationLoss() == null && parsedData.getElevationLoss() != null) {
activity.setElevationLoss(parsedData.getElevationLoss());
}
activityRepository.save(activity);
log.info("Reprocessed FIT elevation profile for activity {} ({} track points)",
activity.getId(), parsedData.getTrackPoints().size());
return true;
} catch (Exception e) {
log.warn("Failed to reprocess FIT elevation for activity {}: {}", activity.getId(), e.getMessage());
return false;
}
}
/**
* Returns true if the given track points JSON already contains at least one
* non-null {@code elevation} value. Used by the FIT elevation backfill to
* skip activities that don't need to be touched.
*/
private boolean trackJsonAlreadyHasElevation(String trackPointsJson) {
if (trackPointsJson == null || trackPointsJson.isEmpty()) {
return false;
}
try {
com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(trackPointsJson);
if (!root.isArray()) {
return false;
}
for (com.fasterxml.jackson.databind.JsonNode point : root) {
com.fasterxml.jackson.databind.JsonNode elevation = point.get("elevation");
if (elevation != null && !elevation.isNull()) {
return true;
}
}
return false;
} catch (Exception e) {
// Malformed JSON shouldn't happen for stored activities, but if it does
// err on the side of "no elevation" so the backfill picks it up.
log.warn("Could not parse track points JSON to check for elevation: {}", e.getMessage());
return false;
}
}
/**
* Detects file format from content and filename.
* Priority: magic bytes > XML header > file extension

View file

@ -156,9 +156,18 @@ public class FitParser {
point.setTimestamp(convertDateTime(record.getTimestamp()));
}
// Extract elevation
if (record.getAltitude() != null) {
point.setElevation(BigDecimal.valueOf(record.getAltitude()).setScale(2, RoundingMode.HALF_UP));
// Extract elevation. Prefer enhanced_altitude (4-byte high-resolution barometric
// value used by modern Garmin devices) and fall back to the legacy altitude field
// if it's not present. Older devices write only the legacy field; newer Edge /
// Fenix / Forerunner units write only the enhanced one. Reading just one of them
// produces empty elevation profiles for everything recorded on the other class
// of device.
Float elevationValue = record.getEnhancedAltitude();
if (elevationValue == null) {
elevationValue = record.getAltitude();
}
if (elevationValue != null) {
point.setElevation(BigDecimal.valueOf(elevationValue).setScale(2, RoundingMode.HALF_UP));
}
// Extract heart rate
@ -176,9 +185,15 @@ public class FitParser {
point.setPower(record.getPower());
}
// Extract speed (convert m/s to km/h)
if (record.getSpeed() != null) {
point.setSpeed(BigDecimal.valueOf(record.getSpeed() * MPS_TO_KPH)
// Extract speed (convert m/s to km/h). Same enhanced-vs-legacy split as altitude:
// modern Garmin devices write enhanced_speed (higher resolution) and may leave the
// legacy speed field null. Read both, preferring enhanced.
Float speedValue = record.getEnhancedSpeed();
if (speedValue == null) {
speedValue = record.getSpeed();
}
if (speedValue != null) {
point.setSpeed(BigDecimal.valueOf(speedValue * MPS_TO_KPH)
.setScale(2, RoundingMode.HALF_UP));
}