258 lines
11 KiB
C#
258 lines
11 KiB
C#
using Dynastream.Fit;
|
||
using FitDateTime = Dynastream.Fit.DateTime;
|
||
|
||
namespace Server.WatchBody.Import;
|
||
|
||
/// <summary>Разобранные факты активности (.fit / TCX / GPX) — единый
|
||
/// вход WorkoutImporter (spec 01/02 m06).</summary>
|
||
public sealed record ActivityFacts(
|
||
string FactsJson,
|
||
System.DateTime StartedAt,
|
||
int? AvgHr,
|
||
int? HrRecoveryS,
|
||
string FitFileId,
|
||
string Device);
|
||
|
||
/// <summary>Файл не активность или повреждён (spec 01 §1).</summary>
|
||
public class FitParseException(string message) : Exception(message);
|
||
|
||
/// <summary>
|
||
/// Потоковый разбор .fit — официальный FIT SDK (tech-req разд. 5):
|
||
/// события Decode.MesgEvent, файл читается один раз. Результат —
|
||
/// канонический факт workout-facts/1 (tech-req 2.3).
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Генерация .fit для тест-фикстур (spec 01 §4) —
|
||
/// энкодер SDK; порядок FileId → DeviceInfo → Record* → Lap* →
|
||
/// Set* → Session.</summary>
|
||
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<LapMesg> _laps = [];
|
||
private readonly List<(System.DateTime time, int hr)> _records = [];
|
||
private readonly List<SetMesg> _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<string, object?>
|
||
{
|
||
["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<string, object?>
|
||
{
|
||
["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<string, object?>
|
||
{
|
||
["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<string, object?>
|
||
{
|
||
["schema"] = "workout-facts/1",
|
||
["session"] = new Dictionary<string, object?>
|
||
{
|
||
["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);
|
||
}
|
||
|
||
/// <summary>Конец последнего Lap → первый Record строго после
|
||
/// него с hr ниже порога (Ф-13.4, spec 01 §3); нет записей или
|
||
/// не опустился — null.</summary>
|
||
private static int? HrRecovery(
|
||
List<System.DateTime> 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'}";
|
||
|
||
}
|
||
}
|