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'}";
}
}