Initial commit: FieldLogger MAUI app with Maglink BLE support

Added Maglink RTK GNSS receiver integration with correct BLE UUIDs and device name filtering (ML-* prefix).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
brentperteet
2026-07-06 13:46:50 -05:00
commit b602b762c9
79 changed files with 5807 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
namespace FieldLogger.Models;
/// <summary>The two kinds of BLE devices the app pairs with.</summary>
public enum DeviceKind
{
/// <summary>Underground Magnetics utility locating receiver (UMRX_* / DT100_*).</summary>
Locator,
/// <summary>Maglink RTK GNSS receiver (H11).</summary>
RtkGps,
}

View File

@@ -0,0 +1,155 @@
using System.Globalization;
namespace FieldLogger.Models;
public enum GnssFixStatus { NoFix = 0, Single = 1, Dgps = 2, RtkFixed = 4, RtkFloat = 5 }
/// <summary>
/// A parsed $GNPOS sentence from the Maglink (H11) RTK receiver.
/// </summary>
public sealed class GnssFix
{
public double Latitude { get; init; }
public double Longitude { get; init; }
public double Altitude { get; init; }
public double AltitudeCorrected { get; init; }
public GnssFixStatus Status { get; init; }
public double Hdop { get; init; }
public double Hrms { get; init; }
public double Vrms { get; init; }
public int SatellitesUsed { get; init; }
public int SatellitesVisible { get; init; }
public double SpeedKmh { get; init; }
public double Heading { get; init; }
public double BatteryVoltage { get; init; }
public int BatteryPercent { get; init; }
public bool NtripConnected { get; init; }
public int RtcmSize { get; init; }
public double CorrectionAgeSeconds { get; init; }
public long UnixTimestamp { get; init; }
public double TiltAngle { get; init; }
public string RawSentence { get; init; } = "";
/// <summary>Local UTC time this fix was received by the app.</summary>
public DateTime ReceivedUtc { get; init; } = DateTime.UtcNow;
public bool HasPosition => Status != GnssFixStatus.NoFix;
public string StatusLabel => Status switch
{
GnssFixStatus.NoFix => "No Fix",
GnssFixStatus.Single => "Single",
GnssFixStatus.Dgps => "DGPS",
GnssFixStatus.RtkFixed => "RTK Fixed",
GnssFixStatus.RtkFloat => "RTK Float",
_ => Status.ToString(),
};
/// <summary>Parses a "$GNPOS,..." sentence (checksum must already be validated).</summary>
public static GnssFix? TryParse(string sentence)
{
var body = NmeaSentence.Body(sentence);
var f = body.Split(',');
if (f.Length < 20 || f[0] != "GNPOS")
return null;
try
{
return new GnssFix
{
Latitude = D(f[1]),
Longitude = D(f[2]),
Altitude = D(f[3]),
AltitudeCorrected = D(f[4]),
Status = (GnssFixStatus)I(f[5]),
Hdop = D(f[6]),
Hrms = D(f[7]),
Vrms = D(f[8]),
SatellitesUsed = I(f[9]),
SatellitesVisible = I(f[10]),
SpeedKmh = D(f[11]),
Heading = D(f[12]),
BatteryVoltage = D(f[13]),
BatteryPercent = I(f[14]),
NtripConnected = I(f[15]) != 0,
RtcmSize = I(f[16]),
CorrectionAgeSeconds = D(f[17]),
UnixTimestamp = long.Parse(f[18], CultureInfo.InvariantCulture),
TiltAngle = D(f[19]),
RawSentence = sentence.Trim(),
};
}
catch (FormatException)
{
return null;
}
}
private static double D(string s) => double.Parse(s, NumberStyles.Float, CultureInfo.InvariantCulture);
private static int I(string s) => int.Parse(s, CultureInfo.InvariantCulture);
}
/// <summary>
/// A parsed $GNDEV device-information sentence from the Maglink receiver.
/// </summary>
public sealed class GnssDeviceInfo
{
public string SerialNumber { get; init; } = "";
public string PcbVersion { get; init; } = "";
public string FirmwareVersion { get; init; } = "";
public string Imei { get; init; } = "";
public string Imsi { get; init; } = "";
public string Iccid { get; init; } = "";
public static GnssDeviceInfo? TryParse(string sentence)
{
var f = NmeaSentence.Body(sentence).Split(',');
if (f.Length < 7 || f[0] != "GNDEV")
return null;
return new GnssDeviceInfo
{
SerialNumber = f[1],
PcbVersion = f[2],
FirmwareVersion = f[3],
Imei = f[4],
Imsi = f[5],
Iccid = f[6],
};
}
}
/// <summary>NMEA framing helpers ($...*hh checksum).</summary>
public static class NmeaSentence
{
/// <summary>Returns the sentence body between '$' and '*' (or end of string).</summary>
public static string Body(string sentence)
{
var s = sentence.Trim().TrimStart('$');
var star = s.IndexOf('*');
return star >= 0 ? s[..star] : s;
}
public static byte Checksum(string sentence)
{
byte checksum = 0;
foreach (var c in Body(sentence))
checksum ^= (byte)c;
return checksum;
}
/// <summary>Validates the trailing *hh checksum. Sentences without a checksum fail validation.</summary>
public static bool VerifyChecksum(string sentence)
{
var star = sentence.LastIndexOf('*');
if (star < 0 || star + 3 > sentence.TrimEnd().Length)
return false;
var hex = sentence.Substring(star + 1, 2);
if (!byte.TryParse(hex, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var expected))
return false;
return Checksum(sentence) == expected;
}
}

23
FieldLogger/Models/Job.cs Normal file
View File

@@ -0,0 +1,23 @@
using SQLite;
namespace FieldLogger.Models;
[Table("jobs")]
public sealed class Job
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[NotNull]
public string Name { get; set; } = "";
public string Notes { get; set; } = "";
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
/// <summary>Reserved for MQTT sync: server-side job identifier once provisioned remotely.</summary>
public string? RemoteId { get; set; }
/// <summary>Reserved for MQTT sync: true once all points have been pushed to the server.</summary>
public bool Synced { get; set; }
}

View File

@@ -0,0 +1,145 @@
using SQLite;
namespace FieldLogger.Models;
/// <summary>
/// One logged locate point: the full UM receiver PBDL packet joined with the
/// GNSS fix that was current when the operator pressed the log button.
/// </summary>
[Table("points")]
public sealed class LoggedPoint
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[Indexed]
public int JobId { get; set; }
public DateTime TimestampUtc { get; set; } = DateTime.UtcNow;
// ---- UM locating receiver (PBDL schema 1) ----
public int Mode { get; set; }
public int Frequency { get; set; }
public int FreqType { get; set; }
public int Signal { get; set; }
public int GainDb { get; set; }
public string DepthRaw { get; set; } = "";
public double? DepthMeters { get; set; }
public string CurrentRaw { get; set; } = "";
public double? CurrentMilliamps { get; set; }
public int CompassAngle { get; set; }
public int GuidanceArrows { get; set; }
public int LdPhase { get; set; }
public bool Clipping { get; set; }
public int DepthCurrentSetting { get; set; }
public int LrArrowStyle { get; set; }
public int AudioVolume { get; set; }
public int AudioModulation { get; set; }
public int AudioSound { get; set; }
public double LocatorBatteryVoltage { get; set; }
public int LocatorBatteryType { get; set; }
public int Backlight { get; set; }
public int Utility { get; set; }
public bool MenuInUse { get; set; }
public string LocatorRawPacket { get; set; } = "";
public string LocatorSerialNumber { get; set; } = "";
public string LocatorModel { get; set; } = "";
// ---- GNSS fix (Maglink $GNPOS) ----
/// <summary>False when no fresh GNSS fix was available at log time; position fields are then unset.</summary>
public bool GpsValid { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public double Altitude { get; set; }
public double AltitudeCorrected { get; set; }
public int FixStatus { get; set; }
public double Hdop { get; set; }
public double Hrms { get; set; }
public double Vrms { get; set; }
public int SatellitesUsed { get; set; }
public int SatellitesVisible { get; set; }
public double SpeedKmh { get; set; }
public double HeadingDegrees { get; set; }
public bool NtripConnected { get; set; }
public double CorrectionAgeSeconds { get; set; }
public long GpsUnixTimestamp { get; set; }
public double TiltAngle { get; set; }
public double GpsBatteryVoltage { get; set; }
public int GpsBatteryPercent { get; set; }
public string GpsRawSentence { get; set; } = "";
public string GpsSerialNumber { get; set; } = "";
/// <summary>Reserved for MQTT sync.</summary>
public bool Synced { get; set; }
[Ignore]
public GnssFixStatus FixStatusEnum => (GnssFixStatus)FixStatus;
[Ignore]
public UmUtility UtilityEnum => (UmUtility)Utility;
public static LoggedPoint From(int jobId, UmLogPacket packet, UmDeviceInfo? locatorInfo,
GnssFix? fix, GnssDeviceInfo? gpsInfo)
{
var p = new LoggedPoint
{
JobId = jobId,
TimestampUtc = DateTime.UtcNow,
Mode = (int)packet.Mode,
Frequency = packet.Frequency,
FreqType = (int)packet.FreqType,
Signal = packet.Signal,
GainDb = packet.GainDb,
DepthRaw = packet.DepthRaw,
DepthMeters = packet.DepthMeters,
CurrentRaw = packet.CurrentRaw,
CurrentMilliamps = packet.CurrentMilliamps,
CompassAngle = packet.CompassAngle,
GuidanceArrows = packet.GuidanceArrows,
LdPhase = packet.LdPhase,
Clipping = packet.Clipping,
DepthCurrentSetting = packet.DepthCurrentSetting,
LrArrowStyle = packet.LrArrowStyle,
AudioVolume = packet.AudioVolume,
AudioModulation = packet.AudioModulation,
AudioSound = packet.AudioSound,
LocatorBatteryVoltage = packet.BatteryVoltage,
LocatorBatteryType = (int)packet.BatteryType,
Backlight = packet.Backlight,
Utility = (int)packet.Utility,
MenuInUse = packet.MenuInUse,
LocatorRawPacket = packet.RawText,
LocatorSerialNumber = locatorInfo?.SerialNumber ?? "",
LocatorModel = locatorInfo?.ModelName ?? "",
GpsSerialNumber = gpsInfo?.SerialNumber ?? "",
};
if (fix is not null)
{
p.GpsValid = fix.HasPosition;
p.Latitude = fix.Latitude;
p.Longitude = fix.Longitude;
p.Altitude = fix.Altitude;
p.AltitudeCorrected = fix.AltitudeCorrected;
p.FixStatus = (int)fix.Status;
p.Hdop = fix.Hdop;
p.Hrms = fix.Hrms;
p.Vrms = fix.Vrms;
p.SatellitesUsed = fix.SatellitesUsed;
p.SatellitesVisible = fix.SatellitesVisible;
p.SpeedKmh = fix.SpeedKmh;
p.HeadingDegrees = fix.Heading;
p.NtripConnected = fix.NtripConnected;
p.CorrectionAgeSeconds = fix.CorrectionAgeSeconds;
p.GpsUnixTimestamp = fix.UnixTimestamp;
p.TiltAngle = fix.TiltAngle;
p.GpsBatteryVoltage = fix.BatteryVoltage;
p.GpsBatteryPercent = fix.BatteryPercent;
p.GpsRawSentence = fix.RawSentence;
}
return p;
}
}

View File

@@ -0,0 +1,51 @@
namespace FieldLogger.Models;
/// <summary>
/// Info string the UM receiver sends the first time push-button data logging is enabled.
/// </summary>
public sealed class UmDeviceInfo
{
public string Manufacturer { get; init; } = "";
public string ModelName { get; init; } = "";
public string SerialNumber { get; init; } = "";
public string BootloaderVersion { get; init; } = "";
public string SoftwareVersion { get; init; } = "";
public string ManufactureDate { get; init; } = "";
public string CalibrationDate { get; init; } = "";
public double HourCount { get; init; }
public int PbdlSchema { get; init; }
public string RawText { get; init; } = "";
/// <summary>
/// Parses the 10-field info string (9 parameters followed by "OK").
/// Returns null if the line does not match.
/// </summary>
public static UmDeviceInfo? TryParse(string line)
{
var fields = line.Trim().Split(',');
if (fields.Length < 10 || !fields[^1].Trim().Equals("OK", StringComparison.OrdinalIgnoreCase))
return null;
// Serial number is 9 digits; use it to distinguish from other OK-terminated responses.
if (fields[2].Trim().Length < 6)
return null;
double.TryParse(fields[7], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var hours);
int.TryParse(fields[8], out var schema);
return new UmDeviceInfo
{
Manufacturer = fields[0].Trim(),
ModelName = fields[1].Trim(),
SerialNumber = fields[2].Trim(),
BootloaderVersion = fields[3].Trim(),
SoftwareVersion = fields[4].Trim(),
ManufactureDate = fields[5].Trim(),
CalibrationDate = fields[6].Trim(),
HourCount = hours,
PbdlSchema = schema,
RawText = line.Trim(),
};
}
}

View File

@@ -0,0 +1,141 @@
using System.Globalization;
namespace FieldLogger.Models;
public enum UmMode { Single = 0, Twin = 1, Null = 2, Sweep = 3, TwinSweep = 4, Omni = 5, TwinOmni = 6 }
public enum UmFreqType { Active = 0, LdActive = 1, Power = 2, GroupedPower = 3, Cathodic = 4, Sonde = 5, Radio = 6, FaultFind = 7 }
public enum UmUtility { None = 0, Gas = 1, Power = 2, Communications = 3, Water = 4, Sewer = 5, Fiber = 6, Other = 7 }
public enum UmBatteryType { NotMeasured = 0, Alkaline = 1, AlkalineError = 2, Lithium = 3 }
/// <summary>
/// A single push-button data-log packet from the UM locating receiver
/// (PBDL schema 1, 21 comma-delimited fields).
/// </summary>
public sealed class UmLogPacket
{
public UmMode Mode { get; init; }
public int Frequency { get; init; }
public UmFreqType FreqType { get; init; }
public int Signal { get; init; } // 0 - 99
public int GainDb { get; init; } // 0 - 145 dB
public string DepthRaw { get; init; } = ""; // "X' Y\"" imperial, "X.Yym" metric, "- - -" no depth
public string CurrentRaw { get; init; } = ""; // 0 - 999mA
public int CompassAngle { get; init; } // 0 - 180 degrees (direction ambiguous)
public int GuidanceArrows { get; init; } // -300 to 300, negative = left
public int LdPhase { get; init; }
public bool Clipping { get; init; }
public int DepthCurrentSetting { get; init; } // 0 ON, 1 AUTO
public int LrArrowStyle { get; init; } // 0 OFF, 1 Style 1, 2 Style 2
public int AudioVolume { get; init; } // 0 - 3
public int AudioModulation { get; init; } // 0 AM, 1 FM
public int AudioSound { get; init; } // 0 Smooth, 1 Rough
public double BatteryVoltage { get; init; }
public UmBatteryType BatteryType { get; init; }
public int Backlight { get; init; } // 0 Low, 1 Medium, 2 High
public UmUtility Utility { get; init; }
public bool MenuInUse { get; init; }
public string RawText { get; init; } = "";
public const int FieldCount = 21;
/// <summary>Depth converted to meters, when the raw string can be interpreted; otherwise null.</summary>
public double? DepthMeters => TryParseDepthMeters(DepthRaw);
/// <summary>Current in mA parsed from the raw string, when possible.</summary>
public double? CurrentMilliamps
{
get
{
var s = CurrentRaw.Trim().TrimEnd('A', 'a').TrimEnd('m', 'M');
return double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
}
}
public static double? TryParseDepthMeters(string raw)
{
raw = raw.Trim();
if (raw.Length == 0 || raw.Contains("- -"))
return null;
// Metric: "X.Yym" e.g. "1.23m"
if (raw.EndsWith("m", StringComparison.OrdinalIgnoreCase))
{
var s = raw[..^1].Trim();
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var meters))
return meters;
return null;
}
// Imperial: feet' inches" e.g. "3' 7"" or "3' 7'"
if (raw.Contains('\''))
{
var parts = raw.Split('\'', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 1 &&
double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var feet))
{
double inches = 0;
if (parts.Length >= 2)
{
var inchStr = parts[1].TrimEnd('"');
double.TryParse(inchStr, NumberStyles.Float, CultureInfo.InvariantCulture, out inches);
}
return (feet + inches / 12.0) * 0.3048;
}
}
return null;
}
/// <summary>
/// Parses a 21-field PBDL schema 1 packet. Returns null if the line does not look like a data packet.
/// </summary>
public static UmLogPacket? TryParse(string line)
{
var fields = line.Trim().Split(',');
if (fields.Length < FieldCount)
return null;
// First field must be a small integer (Mode 0-6) to qualify as a data packet.
if (!int.TryParse(fields[0], out var mode) || mode is < 0 or > 6)
return null;
try
{
return new UmLogPacket
{
Mode = (UmMode)mode,
Frequency = ParseInt(fields[1]),
FreqType = (UmFreqType)ParseInt(fields[2]),
Signal = ParseInt(fields[3]),
GainDb = ParseInt(fields[4]),
DepthRaw = fields[5].Trim(),
CurrentRaw = fields[6].Trim(),
CompassAngle = ParseInt(fields[7]),
GuidanceArrows = ParseInt(fields[8]),
LdPhase = ParseInt(fields[9]),
Clipping = ParseInt(fields[10]) != 0,
DepthCurrentSetting = ParseInt(fields[11]),
LrArrowStyle = ParseInt(fields[12]),
AudioVolume = ParseInt(fields[13]),
AudioModulation = ParseInt(fields[14]),
AudioSound = ParseInt(fields[15]),
BatteryVoltage = ParseDouble(fields[16]),
BatteryType = (UmBatteryType)ParseInt(fields[17]),
Backlight = ParseInt(fields[18]),
Utility = (UmUtility)ParseInt(fields[19]),
MenuInUse = ParseInt(fields[20]) != 0,
RawText = line.Trim(),
};
}
catch (FormatException)
{
return null;
}
}
private static int ParseInt(string s) => int.Parse(s.Trim(), CultureInfo.InvariantCulture);
private static double ParseDouble(string s) => double.Parse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture);
}