using Dynastream.Fit; using FitDateTime = Dynastream.Fit.DateTime; namespace Server.WatchBody.Import; /// Разобранные факты активности (.fit / TCX / GPX) — единый /// вход WorkoutImporter (spec 01/02 m06). public sealed record ActivityFacts( string FactsJson, System.DateTime StartedAt, int? AvgHr, int? HrRecoveryS, string FitFileId, string Device); /// Файл не активность или повреждён (spec 01 §1). public class FitParseException(string message) : Exception(message); /// /// Потоковый разбор .fit — официальный FIT SDK (tech-req разд. 5): /// события Decode.MesgEvent, файл читается один раз. Результат — /// канонический факт workout-facts/1 (tech-req 2.3). /// public class FitParser { private readonly decimal _restingHr; // порог возврата пульса (Р3-А) public FitParser(decimal restingHrSitting = 90) => _restingHr = restingHrSitting; public ActivityFacts Parse(string path) { var session = new SessionAccumulator(); var bytes = System.IO.File.ReadAllBytes(path); using (var probe = new MemoryStream(bytes)) { if (!new Decode().IsFIT(probe)) throw new FitParseException($"файл не .fit: {path}"); probe.Position = 0; if (!new Decode().CheckIntegrity(probe)) throw new FitParseException($"контрольная сумма .fit не сошлась: {path}"); } var decode = new Decode(); decode.MesgEvent += (_, e) => session.OnMesg(e.mesg); using var stream = new MemoryStream(bytes); if (!decode.Read(stream)) throw new FitParseException($"файл не прочитан: {path}"); return session.Build(_restingHr); } /// Генерация .fit для тест-фикстур (spec 01 §4) — /// энкодер SDK; порядок FileId → DeviceInfo → Record* → Lap* → /// Set* → Session. public static void WriteTestFile( string path, System.DateTime startUtc, (int offsetS, int hr)[] records, (int index, int offsetS, int durationS)[] laps, (int reps, decimal? weightKg)[] sets, string? serial = null) { var encode = new Encode(ProtocolVersion.V20); using var output = new FileStream(path, FileMode.Create); encode.Open(output); var fileId = new FileIdMesg(); fileId.SetTimeCreated(new FitDateTime(startUtc)); if (serial != null) fileId.SetSerialNumber(uint.Parse(serial)); encode.Write(fileId); var device = new DeviceInfoMesg(); device.SetTimestamp(new FitDateTime(startUtc)); device.SetGarminProduct(GarminProduct.Fenix6sAsia); encode.Write(device); foreach (var (offsetS, hr) in records) { var record = new RecordMesg(); record.SetTimestamp(new FitDateTime(startUtc.AddSeconds(offsetS))); record.SetHeartRate((byte)hr); encode.Write(record); } foreach (var (index, offsetS, durationS) in laps) { var lap = new LapMesg(); lap.SetStartTime(new FitDateTime(startUtc.AddSeconds(offsetS))); lap.SetTotalTimerTime((float)durationS); encode.Write(lap); } foreach (var (reps, weightKg) in sets) { var set = new SetMesg(); set.SetStartTime(new FitDateTime(startUtc)); set.SetRepetitions((ushort)reps); if (weightKg != null) set.SetWeight((float)weightKg); encode.Write(set); } var session = new SessionMesg(); session.SetStartTime(new FitDateTime(startUtc)); session.SetTotalTimerTime((float)(records.Length > 0 ? records[^1].offsetS + 60 : 3600)); session.SetSport(Sport.Training); encode.Write(session); encode.Close(); } private sealed class SessionAccumulator { private FileIdMesg? _fileId; private SessionMesg? _session; private readonly List _laps = []; private readonly List<(System.DateTime time, int hr)> _records = []; private readonly List _sets = []; private DeviceInfoMesg? _device; public void OnMesg(Mesg mesg) { // SDK доставляет базовый Mesg — типизированные классы // конструируются из него по Num switch (mesg.Num) { case MesgNum.FileId: _fileId = new FileIdMesg(mesg); break; case MesgNum.Session: _session = new SessionMesg(mesg); break; case MesgNum.Lap: _laps.Add(new LapMesg(mesg)); break; case MesgNum.Record: { var record = new RecordMesg(mesg); if (record.GetHeartRate().HasValue && record.GetHeartRate().Value > 0 && record.GetTimestamp() != null) _records.Add((record.GetTimestamp().GetDateTime().ToUniversalTime(), record.GetHeartRate().Value)); break; } case MesgNum.Set: _sets.Add(new SetMesg(mesg)); break; case MesgNum.DeviceInfo: { var device = new DeviceInfoMesg(mesg); if (device.GetTimestamp() != null && (_device == null || device.GetTimestamp().GetDateTime() >= _device.GetTimestamp().GetDateTime())) _device = device; break; } } } public ActivityFacts Build(decimal restingHr) { if (_session?.GetStartTime() == null) throw new FitParseException("в файле нет Session — это не активность"); var start = _session.GetStartTime().GetDateTime().ToUniversalTime(); var durationS = _session.GetTotalTimerTime() != null ? (int)Math.Round(_session.GetTotalTimerTime().Value, MidpointRounding.AwayFromZero) : 0; var lapsJson = _laps .Where(l => l.GetStartTime() != null) .Select((l, i) => new Dictionary { ["index"] = i, ["start"] = l.GetStartTime().GetDateTime().ToUniversalTime() .ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"), ["duration_s"] = l.GetTotalTimerTime() != null ? (int)Math.Round(l.GetTotalTimerTime().Value, MidpointRounding.AwayFromZero) : 0, }) .ToList(); var recordsJson = _records .Select(r => new Dictionary { ["t_offset_s"] = (int)(r.time - start).TotalSeconds, ["hr"] = r.hr, }) .ToList(); var setsJson = _sets .Where(s => s.GetRepetitions().HasValue && s.GetRepetitions().Value > 0) .Select((s, i) => { var item = new Dictionary { ["index"] = i, ["reps"] = s.GetRepetitions().Value, }; if (s.GetWeight().HasValue && s.GetWeight().Value > 0) item["weight_kg"] = Math.Round((decimal)s.GetWeight().Value, 1, MidpointRounding.AwayFromZero); return item; }) .ToList(); var fitFileId = ComposeFileId(); var device = _device == null ? string.Empty : _device.GetGarminProduct().HasValue ? _device.GetGarminProduct().Value.ToString() : _device.GetProduct()?.ToString() ?? string.Empty; var facts = new Dictionary { ["schema"] = "workout-facts/1", ["session"] = new Dictionary { ["type"] = _session.GetSport().ToString(), ["start"] = start.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"), ["duration_s"] = durationS, }, ["laps"] = lapsJson, ["records"] = recordsJson, ["sets"] = setsJson, ["fit_file_id"] = fitFileId, ["device"] = device, }; int? avgHr = recordsJson.Count == 0 ? null : (int)Math.Round(recordsJson.Average(r => (int)r["hr"]!), MidpointRounding.AwayFromZero); var lapEnds = _laps .Where(l => l.GetStartTime() != null && l.GetTotalTimerTime() != null) .Select(l => l.GetStartTime().GetDateTime().ToUniversalTime() .AddSeconds(l.GetTotalTimerTime().Value)) .ToList(); var recovery = HrRecovery(lapEnds, _records, restingHr); return new ActivityFacts( System.Text.Json.JsonSerializer.Serialize(facts), start, avgHr, recovery, fitFileId, device); } /// Конец последнего Lap → первый Record строго после /// него с hr ниже порога (Ф-13.4, spec 01 §3); нет записей или /// не опустился — null. private static int? HrRecovery( List lapEnds, List<(System.DateTime time, int hr)> records, decimal restingHr) { if (lapEnds.Count == 0) return null; var lastEnd = lapEnds.Max(); var after = records .Where(r => r.time > lastEnd) .OrderBy(r => r.time) .ToList(); var hit = after.FirstOrDefault(r => r.hr < restingHr); if (hit == default) return null; return (int)Math.Round((hit.time - lastEnd).TotalSeconds, MidpointRounding.AwayFromZero); } private string ComposeFileId() => _fileId == null ? throw new FitParseException("в файле нет FileId — не Garmin-экспорт") : _fileId.GetTimeCreated() == null ? throw new FitParseException("FileId без TimeCreated") : $"{(_fileId.GetSerialNumber()?.ToString() ?? "nosn")}-" + $"{_fileId.GetTimeCreated().GetDateTime().ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}"; } }