commit b602b762c953fc988b717fee8219fcf6c03ec695 Author: brentperteet Date: Mon Jul 6 13:46:50 2026 -0500 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 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..56a3277 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(git init)", + "Bash(git add .)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc78471 --- /dev/null +++ b/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/FieldLogger.sln b/FieldLogger.sln new file mode 100644 index 0000000..b484305 --- /dev/null +++ b/FieldLogger.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FieldLogger", "FieldLogger\FieldLogger.csproj", "{B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Debug|x64.ActiveCfg = Debug|Any CPU + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Debug|x64.Build.0 = Debug|Any CPU + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Debug|x86.ActiveCfg = Debug|Any CPU + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Debug|x86.Build.0 = Debug|Any CPU + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Release|Any CPU.Build.0 = Release|Any CPU + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Release|x64.ActiveCfg = Release|Any CPU + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Release|x64.Build.0 = Release|Any CPU + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Release|x86.ActiveCfg = Release|Any CPU + {B58DBFE2-0486-45C8-A9BE-BB1EBA814D89}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/FieldLogger/App.xaml b/FieldLogger/App.xaml new file mode 100644 index 0000000..d653539 --- /dev/null +++ b/FieldLogger/App.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/FieldLogger/App.xaml.cs b/FieldLogger/App.xaml.cs new file mode 100644 index 0000000..1711798 --- /dev/null +++ b/FieldLogger/App.xaml.cs @@ -0,0 +1,14 @@ +namespace FieldLogger; + +public partial class App : Application +{ + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window(new AppShell()); + } +} \ No newline at end of file diff --git a/FieldLogger/AppShell.xaml b/FieldLogger/AppShell.xaml new file mode 100644 index 0000000..48f1b29 --- /dev/null +++ b/FieldLogger/AppShell.xaml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/FieldLogger/AppShell.xaml.cs b/FieldLogger/AppShell.xaml.cs new file mode 100644 index 0000000..ed0bb23 --- /dev/null +++ b/FieldLogger/AppShell.xaml.cs @@ -0,0 +1,14 @@ +using FieldLogger.Views; + +namespace FieldLogger; + +public partial class AppShell : Shell +{ + public AppShell() + { + InitializeComponent(); + + Routing.RegisterRoute("devicescan", typeof(DeviceScanPage)); + Routing.RegisterRoute("jobdetail", typeof(JobDetailPage)); + } +} diff --git a/FieldLogger/Converters/AppConverters.cs b/FieldLogger/Converters/AppConverters.cs new file mode 100644 index 0000000..ab9bcb4 --- /dev/null +++ b/FieldLogger/Converters/AppConverters.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using FieldLogger.Models; +using FieldLogger.Services; + +namespace FieldLogger.Converters; + +public sealed class ConnectionStateToColorConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value switch + { + ConnectionState.Connected => Colors.LimeGreen, + ConnectionState.Connecting => Colors.Orange, + _ => Colors.IndianRed, + }; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public sealed class ConnectionStateToTextConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value switch + { + ConnectionState.Connected => "Connected", + ConnectionState.Connecting => "Connecting…", + _ => "Disconnected", + }; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public sealed class UtilityToNameConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is int i ? ((UmUtility)i).ToString() : ""; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public sealed class InvertBoolConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool b && !b; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool b && !b; +} + +public sealed class FixStatusToNameConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is int i ? (GnssFixStatus)i switch + { + GnssFixStatus.NoFix => "No Fix", + GnssFixStatus.Single => "Single", + GnssFixStatus.Dgps => "DGPS", + GnssFixStatus.RtkFixed => "RTK Fixed", + GnssFixStatus.RtkFloat => "RTK Float", + _ => "?", + } : "—"; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/FieldLogger/FieldLogger.csproj b/FieldLogger/FieldLogger.csproj new file mode 100644 index 0000000..e1e8450 --- /dev/null +++ b/FieldLogger/FieldLogger.csproj @@ -0,0 +1,82 @@ + + + + net9.0-android;net9.0-ios;net9.0-maccatalyst + $(TargetFrameworks);net9.0-windows10.0.19041.0 + + + + + + + Exe + FieldLogger + true + true + enable + enable + + + $(NoWarn);MVVMTK0045 + + + Field Logger + + + com.undergroundmagnetics.fieldlogger + + + 1.0 + 1 + + + None + + 15.0 + 15.0 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + 6.5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FieldLogger/GlobalXmlns.cs b/FieldLogger/GlobalXmlns.cs new file mode 100644 index 0000000..06a9eac --- /dev/null +++ b/FieldLogger/GlobalXmlns.cs @@ -0,0 +1,2 @@ +[assembly: XmlnsDefinition("http://schemas.microsoft.com/dotnet/maui/global", "FieldLogger")] +[assembly: XmlnsDefinition("http://schemas.microsoft.com/dotnet/maui/global", "FieldLogger.Pages")] diff --git a/FieldLogger/MauiProgram.cs b/FieldLogger/MauiProgram.cs new file mode 100644 index 0000000..a0a4bfa --- /dev/null +++ b/FieldLogger/MauiProgram.cs @@ -0,0 +1,64 @@ +using FieldLogger.Services; +using FieldLogger.Services.Ble; +using FieldLogger.Services.Data; +using FieldLogger.Services.Sync; +using FieldLogger.ViewModels; +using FieldLogger.Views; +using Microsoft.Extensions.Logging; + +namespace FieldLogger; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() +#if !WINDOWS + .UseMauiMaps() +#endif + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); + }); + +#if DEBUG + builder.Logging.AddDebug(); +#endif + + // Core services + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // View models + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + + // Pages + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + + var app = builder.Build(); + + // Instantiate the point logger so it listens for receiver packets from startup. + _ = app.Services.GetRequiredService(); + + return app; + } +} diff --git a/FieldLogger/Models/DeviceKind.cs b/FieldLogger/Models/DeviceKind.cs new file mode 100644 index 0000000..d4c251e --- /dev/null +++ b/FieldLogger/Models/DeviceKind.cs @@ -0,0 +1,11 @@ +namespace FieldLogger.Models; + +/// The two kinds of BLE devices the app pairs with. +public enum DeviceKind +{ + /// Underground Magnetics utility locating receiver (UMRX_* / DT100_*). + Locator, + + /// Maglink RTK GNSS receiver (H11). + RtkGps, +} diff --git a/FieldLogger/Models/GnssFix.cs b/FieldLogger/Models/GnssFix.cs new file mode 100644 index 0000000..338cc8f --- /dev/null +++ b/FieldLogger/Models/GnssFix.cs @@ -0,0 +1,155 @@ +using System.Globalization; + +namespace FieldLogger.Models; + +public enum GnssFixStatus { NoFix = 0, Single = 1, Dgps = 2, RtkFixed = 4, RtkFloat = 5 } + +/// +/// A parsed $GNPOS sentence from the Maglink (H11) RTK receiver. +/// +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; } = ""; + + /// Local UTC time this fix was received by the app. + 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(), + }; + + /// Parses a "$GNPOS,..." sentence (checksum must already be validated). + 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); +} + +/// +/// A parsed $GNDEV device-information sentence from the Maglink receiver. +/// +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], + }; + } +} + +/// NMEA framing helpers ($...*hh checksum). +public static class NmeaSentence +{ + /// Returns the sentence body between '$' and '*' (or end of string). + 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; + } + + /// Validates the trailing *hh checksum. Sentences without a checksum fail validation. + 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; + } +} diff --git a/FieldLogger/Models/Job.cs b/FieldLogger/Models/Job.cs new file mode 100644 index 0000000..0b6ff8c --- /dev/null +++ b/FieldLogger/Models/Job.cs @@ -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; + + /// Reserved for MQTT sync: server-side job identifier once provisioned remotely. + public string? RemoteId { get; set; } + + /// Reserved for MQTT sync: true once all points have been pushed to the server. + public bool Synced { get; set; } +} diff --git a/FieldLogger/Models/LoggedPoint.cs b/FieldLogger/Models/LoggedPoint.cs new file mode 100644 index 0000000..ca83cb9 --- /dev/null +++ b/FieldLogger/Models/LoggedPoint.cs @@ -0,0 +1,145 @@ +using SQLite; + +namespace FieldLogger.Models; + +/// +/// 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. +/// +[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) ---- + /// False when no fresh GNSS fix was available at log time; position fields are then unset. + 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; } = ""; + + /// Reserved for MQTT sync. + 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; + } +} diff --git a/FieldLogger/Models/UmDeviceInfo.cs b/FieldLogger/Models/UmDeviceInfo.cs new file mode 100644 index 0000000..5859535 --- /dev/null +++ b/FieldLogger/Models/UmDeviceInfo.cs @@ -0,0 +1,51 @@ +namespace FieldLogger.Models; + +/// +/// Info string the UM receiver sends the first time push-button data logging is enabled. +/// +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; } = ""; + + /// + /// Parses the 10-field info string (9 parameters followed by "OK"). + /// Returns null if the line does not match. + /// + 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(), + }; + } +} diff --git a/FieldLogger/Models/UmLogPacket.cs b/FieldLogger/Models/UmLogPacket.cs new file mode 100644 index 0000000..590fe18 --- /dev/null +++ b/FieldLogger/Models/UmLogPacket.cs @@ -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 } + +/// +/// A single push-button data-log packet from the UM locating receiver +/// (PBDL schema 1, 21 comma-delimited fields). +/// +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; + + /// Depth converted to meters, when the raw string can be interpreted; otherwise null. + public double? DepthMeters => TryParseDepthMeters(DepthRaw); + + /// Current in mA parsed from the raw string, when possible. + 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; + } + + /// + /// Parses a 21-field PBDL schema 1 packet. Returns null if the line does not look like a data packet. + /// + 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); +} diff --git a/FieldLogger/Platforms/Android/AndroidManifest.xml b/FieldLogger/Platforms/Android/AndroidManifest.xml new file mode 100644 index 0000000..0869368 --- /dev/null +++ b/FieldLogger/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/FieldLogger/Platforms/Android/MainActivity.cs b/FieldLogger/Platforms/Android/MainActivity.cs new file mode 100644 index 0000000..a1fc0aa --- /dev/null +++ b/FieldLogger/Platforms/Android/MainActivity.cs @@ -0,0 +1,10 @@ +using Android.App; +using Android.Content.PM; +using Android.OS; + +namespace FieldLogger; + +[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +public class MainActivity : MauiAppCompatActivity +{ +} diff --git a/FieldLogger/Platforms/Android/MainApplication.cs b/FieldLogger/Platforms/Android/MainApplication.cs new file mode 100644 index 0000000..266dcf5 --- /dev/null +++ b/FieldLogger/Platforms/Android/MainApplication.cs @@ -0,0 +1,15 @@ +using Android.App; +using Android.Runtime; + +namespace FieldLogger; + +[Application] +public class MainApplication : MauiApplication +{ + public MainApplication(IntPtr handle, JniHandleOwnership ownership) + : base(handle, ownership) + { + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/FieldLogger/Platforms/Android/Resources/values/colors.xml b/FieldLogger/Platforms/Android/Resources/values/colors.xml new file mode 100644 index 0000000..c04d749 --- /dev/null +++ b/FieldLogger/Platforms/Android/Resources/values/colors.xml @@ -0,0 +1,6 @@ + + + #512BD4 + #2B0B98 + #2B0B98 + \ No newline at end of file diff --git a/FieldLogger/Platforms/MacCatalyst/AppDelegate.cs b/FieldLogger/Platforms/MacCatalyst/AppDelegate.cs new file mode 100644 index 0000000..c73772e --- /dev/null +++ b/FieldLogger/Platforms/MacCatalyst/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace FieldLogger; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/FieldLogger/Platforms/MacCatalyst/Entitlements.plist b/FieldLogger/Platforms/MacCatalyst/Entitlements.plist new file mode 100644 index 0000000..723ee99 --- /dev/null +++ b/FieldLogger/Platforms/MacCatalyst/Entitlements.plist @@ -0,0 +1,17 @@ + + + + + + + com.apple.security.app-sandbox + + + com.apple.security.network.client + + + com.apple.security.device.bluetooth + + + + diff --git a/FieldLogger/Platforms/MacCatalyst/Info.plist b/FieldLogger/Platforms/MacCatalyst/Info.plist new file mode 100644 index 0000000..54a53df --- /dev/null +++ b/FieldLogger/Platforms/MacCatalyst/Info.plist @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + UIDeviceFamily + + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + NSBluetoothAlwaysUsageDescription + Field Logger uses Bluetooth to connect to your Underground Magnetics locating receiver and Maglink RTK GPS receiver. + NSLocationWhenInUseUsageDescription + Field Logger uses your location to display logged points on the map. + + diff --git a/FieldLogger/Platforms/MacCatalyst/Program.cs b/FieldLogger/Platforms/MacCatalyst/Program.cs new file mode 100644 index 0000000..450e367 --- /dev/null +++ b/FieldLogger/Platforms/MacCatalyst/Program.cs @@ -0,0 +1,15 @@ +using ObjCRuntime; +using UIKit; + +namespace FieldLogger; + +public class Program +{ + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/FieldLogger/Platforms/Tizen/Main.cs b/FieldLogger/Platforms/Tizen/Main.cs new file mode 100644 index 0000000..84560c5 --- /dev/null +++ b/FieldLogger/Platforms/Tizen/Main.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Maui; +using Microsoft.Maui.Hosting; + +namespace FieldLogger; + +class Program : MauiApplication +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + + static void Main(string[] args) + { + var app = new Program(); + app.Run(args); + } +} diff --git a/FieldLogger/Platforms/Tizen/tizen-manifest.xml b/FieldLogger/Platforms/Tizen/tizen-manifest.xml new file mode 100644 index 0000000..74f192d --- /dev/null +++ b/FieldLogger/Platforms/Tizen/tizen-manifest.xml @@ -0,0 +1,15 @@ + + + + + + maui-appicon-placeholder + + + + + http://tizen.org/privilege/internet + + + + \ No newline at end of file diff --git a/FieldLogger/Platforms/Windows/App.xaml b/FieldLogger/Platforms/Windows/App.xaml new file mode 100644 index 0000000..bd85744 --- /dev/null +++ b/FieldLogger/Platforms/Windows/App.xaml @@ -0,0 +1,8 @@ + + + diff --git a/FieldLogger/Platforms/Windows/App.xaml.cs b/FieldLogger/Platforms/Windows/App.xaml.cs new file mode 100644 index 0000000..79ca7e5 --- /dev/null +++ b/FieldLogger/Platforms/Windows/App.xaml.cs @@ -0,0 +1,24 @@ +using Microsoft.UI.Xaml; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace FieldLogger.WinUI; + +/// +/// Provides application-specific behavior to supplement the default Application class. +/// +public partial class App : MauiWinUIApplication +{ + /// + /// Initializes the singleton application object. This is the first line of authored code + /// executed, and as such is the logical equivalent of main() or WinMain(). + /// + public App() + { + this.InitializeComponent(); + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} + diff --git a/FieldLogger/Platforms/Windows/Package.appxmanifest b/FieldLogger/Platforms/Windows/Package.appxmanifest new file mode 100644 index 0000000..1af6523 --- /dev/null +++ b/FieldLogger/Platforms/Windows/Package.appxmanifest @@ -0,0 +1,47 @@ + + + + + + + + + $placeholder$ + User Name + $placeholder$.png + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FieldLogger/Platforms/Windows/app.manifest b/FieldLogger/Platforms/Windows/app.manifest new file mode 100644 index 0000000..ba623f7 --- /dev/null +++ b/FieldLogger/Platforms/Windows/app.manifest @@ -0,0 +1,15 @@ + + + + + + + + true/PM + PerMonitorV2, PerMonitor + + + diff --git a/FieldLogger/Platforms/iOS/AppDelegate.cs b/FieldLogger/Platforms/iOS/AppDelegate.cs new file mode 100644 index 0000000..c73772e --- /dev/null +++ b/FieldLogger/Platforms/iOS/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace FieldLogger; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/FieldLogger/Platforms/iOS/Info.plist b/FieldLogger/Platforms/iOS/Info.plist new file mode 100644 index 0000000..914b3ec --- /dev/null +++ b/FieldLogger/Platforms/iOS/Info.plist @@ -0,0 +1,36 @@ + + + + + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + NSBluetoothAlwaysUsageDescription + Field Logger uses Bluetooth to connect to your Underground Magnetics locating receiver and Maglink RTK GPS receiver. + NSLocationWhenInUseUsageDescription + Field Logger uses your location to display logged points on the map. + + diff --git a/FieldLogger/Platforms/iOS/Program.cs b/FieldLogger/Platforms/iOS/Program.cs new file mode 100644 index 0000000..450e367 --- /dev/null +++ b/FieldLogger/Platforms/iOS/Program.cs @@ -0,0 +1,15 @@ +using ObjCRuntime; +using UIKit; + +namespace FieldLogger; + +public class Program +{ + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/FieldLogger/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/FieldLogger/Platforms/iOS/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..24ab3b4 --- /dev/null +++ b/FieldLogger/Platforms/iOS/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,51 @@ + + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + + + diff --git a/FieldLogger/Properties/launchSettings.json b/FieldLogger/Properties/launchSettings.json new file mode 100644 index 0000000..4f85793 --- /dev/null +++ b/FieldLogger/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Windows Machine": { + "commandName": "Project", + "nativeDebugging": false + } + } +} \ No newline at end of file diff --git a/FieldLogger/Resources/AppIcon/appicon.svg b/FieldLogger/Resources/AppIcon/appicon.svg new file mode 100644 index 0000000..9d63b65 --- /dev/null +++ b/FieldLogger/Resources/AppIcon/appicon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/FieldLogger/Resources/AppIcon/appiconfg.svg b/FieldLogger/Resources/AppIcon/appiconfg.svg new file mode 100644 index 0000000..21dfb25 --- /dev/null +++ b/FieldLogger/Resources/AppIcon/appiconfg.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/FieldLogger/Resources/Fonts/OpenSans-Regular.ttf b/FieldLogger/Resources/Fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000..1aed1f2 Binary files /dev/null and b/FieldLogger/Resources/Fonts/OpenSans-Regular.ttf differ diff --git a/FieldLogger/Resources/Fonts/OpenSans-Semibold.ttf b/FieldLogger/Resources/Fonts/OpenSans-Semibold.ttf new file mode 100644 index 0000000..69ec877 Binary files /dev/null and b/FieldLogger/Resources/Fonts/OpenSans-Semibold.ttf differ diff --git a/FieldLogger/Resources/Images/dotnet_bot.png b/FieldLogger/Resources/Images/dotnet_bot.png new file mode 100644 index 0000000..1d1b981 Binary files /dev/null and b/FieldLogger/Resources/Images/dotnet_bot.png differ diff --git a/FieldLogger/Resources/Raw/AboutAssets.txt b/FieldLogger/Resources/Raw/AboutAssets.txt new file mode 100644 index 0000000..89dc758 --- /dev/null +++ b/FieldLogger/Resources/Raw/AboutAssets.txt @@ -0,0 +1,15 @@ +Any raw assets you want to be deployed with your application can be placed in +this directory (and child directories). Deployment of the asset to your application +is automatically handled by the following `MauiAsset` Build Action within your `.csproj`. + + + +These files will be deployed with your package and will be accessible using Essentials: + + async Task LoadMauiAsset() + { + using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt"); + using var reader = new StreamReader(stream); + + var contents = reader.ReadToEnd(); + } diff --git a/FieldLogger/Resources/Raw/map.html b/FieldLogger/Resources/Raw/map.html new file mode 100644 index 0000000..8e4860e --- /dev/null +++ b/FieldLogger/Resources/Raw/map.html @@ -0,0 +1,64 @@ + + + + + + + + +
+ + + + diff --git a/FieldLogger/Resources/Splash/splash.svg b/FieldLogger/Resources/Splash/splash.svg new file mode 100644 index 0000000..21dfb25 --- /dev/null +++ b/FieldLogger/Resources/Splash/splash.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/FieldLogger/Resources/Styles/Colors.xaml b/FieldLogger/Resources/Styles/Colors.xaml new file mode 100644 index 0000000..30307a5 --- /dev/null +++ b/FieldLogger/Resources/Styles/Colors.xaml @@ -0,0 +1,45 @@ + + + + + + + #512BD4 + #ac99ea + #242424 + #DFD8F7 + #9880e5 + #2B0B98 + + White + Black + #D600AA + #190649 + #1f1f1f + + #E1E1E1 + #C8C8C8 + #ACACAC + #919191 + #6E6E6E + #404040 + #212121 + #141414 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/FieldLogger/Resources/Styles/Styles.xaml b/FieldLogger/Resources/Styles/Styles.xaml new file mode 100644 index 0000000..fdb0cd7 --- /dev/null +++ b/FieldLogger/Resources/Styles/Styles.xaml @@ -0,0 +1,444 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FieldLogger/Services/Ble/BleScanner.cs b/FieldLogger/Services/Ble/BleScanner.cs new file mode 100644 index 0000000..60eb56a --- /dev/null +++ b/FieldLogger/Services/Ble/BleScanner.cs @@ -0,0 +1,81 @@ +using Plugin.BLE; +using Plugin.BLE.Abstractions.Contracts; +using Plugin.BLE.Abstractions.EventArgs; + +namespace FieldLogger.Services.Ble; + +/// A device found during a BLE scan. +public sealed record DiscoveredDevice(Guid Id, string Name, int Rssi); + +/// Thin scan wrapper over Plugin.BLE with per-platform permission handling. +public sealed class BleScanner +{ + private readonly IAdapter _adapter = CrossBluetoothLE.Current.Adapter; + + public event EventHandler? DeviceDiscovered; + + public bool IsScanning => _adapter.IsScanning; + + /// + /// Scans for BLE devices for the given duration, raising DeviceDiscovered as devices appear. + /// Only named devices are reported. + /// + public async Task ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default) + { + await EnsurePermissionsAsync(); + + if (!CrossBluetoothLE.Current.IsOn) + throw new InvalidOperationException("Bluetooth is turned off."); + + void OnDiscovered(object? sender, DeviceEventArgs e) + { + if (!string.IsNullOrWhiteSpace(e.Device.Name)) + DeviceDiscovered?.Invoke(this, new DiscoveredDevice(e.Device.Id, e.Device.Name, e.Device.Rssi)); + } + + _adapter.DeviceDiscovered += OnDiscovered; + _adapter.DeviceAdvertised += OnDiscovered; + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(duration); + _adapter.ScanTimeout = (int)duration.TotalMilliseconds; + try + { + await _adapter.StartScanningForDevicesAsync(cancellationToken: timeout.Token); + } + catch (OperationCanceledException) + { + // Scan window elapsed or caller cancelled - both are normal. + } + } + finally + { + _adapter.DeviceDiscovered -= OnDiscovered; + _adapter.DeviceAdvertised -= OnDiscovered; + if (_adapter.IsScanning) + await _adapter.StopScanningForDevicesAsync(); + } + } + + /// Requests the runtime permissions BLE scanning needs (Android only; no-op elsewhere). + public static async Task EnsurePermissionsAsync() + { +#if ANDROID + if (OperatingSystem.IsAndroidVersionAtLeast(31)) + { + var status = await Permissions.RequestAsync(); + if (status != PermissionStatus.Granted) + throw new PermissionException("Bluetooth permission was denied."); + } + else + { + var status = await Permissions.RequestAsync(); + if (status != PermissionStatus.Granted) + throw new PermissionException("Location permission (required for BLE scanning) was denied."); + } +#else + await Task.CompletedTask; +#endif + } +} diff --git a/FieldLogger/Services/Ble/BleSerialClient.cs b/FieldLogger/Services/Ble/BleSerialClient.cs new file mode 100644 index 0000000..9bfe960 --- /dev/null +++ b/FieldLogger/Services/Ble/BleSerialClient.cs @@ -0,0 +1,165 @@ +using System.Text; +using Plugin.BLE; +using Plugin.BLE.Abstractions; +using Plugin.BLE.Abstractions.Contracts; +using Plugin.BLE.Abstractions.EventArgs; + +namespace FieldLogger.Services.Ble; + +/// +/// Line-oriented serial client over a BLE GATT "UART" style service +/// (one notify characteristic for RX, one write characteristic for TX). +/// Both the UM receiver and the Maglink RTK receiver expose this pattern. +/// +public sealed class BleSerialClient : IAsyncDisposable +{ + private readonly IAdapter _adapter; + private readonly StringBuilder _rxBuffer = new(); + private IDevice? _device; + private ICharacteristic? _notifyChar; + private ICharacteristic? _writeChar; + + public event EventHandler? LineReceived; + public event EventHandler? Disconnected; + + public bool IsConnected => _device?.State == DeviceState.Connected; + public string? DeviceName => _device?.Name; + public Guid? DeviceId => _device?.Id; + + public BleSerialClient() + { + _adapter = CrossBluetoothLE.Current.Adapter; + _adapter.DeviceDisconnected += OnDeviceDisconnected; + _adapter.DeviceConnectionLost += OnDeviceConnectionLost; + } + + /// + /// Connects to a known device by id, discovers the given service, and + /// enables notifications. Throws on failure. + /// + public async Task ConnectAsync(Guid deviceId, Guid serviceUuid, Guid notifyUuid, Guid writeUuid, + CancellationToken cancellationToken = default) + { + await DisconnectAsync().ConfigureAwait(false); + + var device = await _adapter.ConnectToKnownDeviceAsync( + deviceId, + new ConnectParameters(autoConnect: false, forceBleTransport: true), + cancellationToken).ConfigureAwait(false); + + try + { + // UM receivers request MTU 247; only has an effect on Android, harmless elsewhere. + try { await device.RequestMtuAsync(247).ConfigureAwait(false); } + catch { /* not supported on this platform */ } + + var service = await device.GetServiceAsync(serviceUuid, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Service {serviceUuid} not found on {device.Name}."); + + var notifyChar = await service.GetCharacteristicAsync(notifyUuid).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Notify characteristic {notifyUuid} not found."); + var writeChar = await service.GetCharacteristicAsync(writeUuid).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Write characteristic {writeUuid} not found."); + + writeChar.WriteType = + writeChar.Properties.HasFlag(CharacteristicPropertyType.WriteWithoutResponse) + ? CharacteristicWriteType.WithoutResponse + : CharacteristicWriteType.WithResponse; + + notifyChar.ValueUpdated += OnValueUpdated; + await notifyChar.StartUpdatesAsync(cancellationToken).ConfigureAwait(false); + + _device = device; + _notifyChar = notifyChar; + _writeChar = writeChar; + } + catch + { + try { await _adapter.DisconnectDeviceAsync(device).ConfigureAwait(false); } catch { } + throw; + } + } + + /// Writes an ASCII string followed by CR+LF to the device. + public Task WriteLineAsync(string text, CancellationToken cancellationToken = default) + => WriteAsync(text + "\r\n", cancellationToken); + + public async Task WriteAsync(string text, CancellationToken cancellationToken = default) + { + var writeChar = _writeChar ?? throw new InvalidOperationException("Not connected."); + await writeChar.WriteAsync(Encoding.ASCII.GetBytes(text), cancellationToken).ConfigureAwait(false); + } + + public async Task DisconnectAsync() + { + var device = _device; + var notifyChar = _notifyChar; + _device = null; + _notifyChar = null; + _writeChar = null; + + if (notifyChar is not null) + { + notifyChar.ValueUpdated -= OnValueUpdated; + try { await notifyChar.StopUpdatesAsync().ConfigureAwait(false); } catch { } + } + + if (device is not null) + { + try { await _adapter.DisconnectDeviceAsync(device).ConfigureAwait(false); } catch { } + device.Dispose(); + } + + lock (_rxBuffer) + _rxBuffer.Clear(); + } + + private void OnValueUpdated(object? sender, CharacteristicUpdatedEventArgs e) + { + var bytes = e.Characteristic.Value; + if (bytes is null || bytes.Length == 0) + return; + + List lines = new(); + lock (_rxBuffer) + { + _rxBuffer.Append(Encoding.ASCII.GetString(bytes)); + var buffered = _rxBuffer.ToString(); + int newline; + while ((newline = buffered.IndexOf('\n')) >= 0) + { + var line = buffered[..newline].TrimEnd('\r'); + buffered = buffered[(newline + 1)..]; + if (line.Length > 0) + lines.Add(line); + } + _rxBuffer.Clear(); + _rxBuffer.Append(buffered); + } + + foreach (var line in lines) + LineReceived?.Invoke(this, line); + } + + private void OnDeviceDisconnected(object? sender, DeviceEventArgs e) => HandleDisconnect(e.Device); + + private void OnDeviceConnectionLost(object? sender, DeviceErrorEventArgs e) => HandleDisconnect(e.Device); + + private void HandleDisconnect(IDevice device) + { + if (_device is null || device.Id != _device.Id) + return; + + _device = null; + _notifyChar = null; + _writeChar = null; + Disconnected?.Invoke(this, EventArgs.Empty); + } + + public async ValueTask DisposeAsync() + { + _adapter.DeviceDisconnected -= OnDeviceDisconnected; + _adapter.DeviceConnectionLost -= OnDeviceConnectionLost; + await DisconnectAsync().ConfigureAwait(false); + } +} diff --git a/FieldLogger/Services/Data/AppDatabase.cs b/FieldLogger/Services/Data/AppDatabase.cs new file mode 100644 index 0000000..ec4150b --- /dev/null +++ b/FieldLogger/Services/Data/AppDatabase.cs @@ -0,0 +1,90 @@ +using FieldLogger.Models; +using SQLite; + +namespace FieldLogger.Services.Data; + +/// Local SQLite store for jobs and logged points. +public sealed class AppDatabase +{ + private readonly Lazy> _connection; + + public AppDatabase() + { + _connection = new Lazy>(async () => + { + var path = Path.Combine(FileSystem.AppDataDirectory, "fieldlogger.db3"); + var db = new SQLiteAsyncConnection(path, + SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache); + await db.CreateTableAsync(); + await db.CreateTableAsync(); + return db; + }); + } + + private Task Db => _connection.Value; + + // ---- Jobs ---- + + public async Task> GetJobsAsync() + { + var db = await Db; + return await db.Table().OrderByDescending(j => j.CreatedUtc).ToListAsync(); + } + + public async Task GetJobAsync(int id) + { + var db = await Db; + return await db.Table().Where(j => j.Id == id).FirstOrDefaultAsync(); + } + + public async Task CreateJobAsync(string name, string notes = "") + { + var db = await Db; + var job = new Job { Name = name, Notes = notes }; + await db.InsertAsync(job); + return job; + } + + public async Task UpdateJobAsync(Job job) + { + var db = await Db; + await db.UpdateAsync(job); + } + + public async Task DeleteJobAsync(int jobId) + { + var db = await Db; + await db.Table().DeleteAsync(p => p.JobId == jobId); + await db.DeleteAsync(jobId); + } + + // ---- Points ---- + + public async Task AddPointAsync(LoggedPoint point) + { + var db = await Db; + await db.InsertAsync(point); + return point.Id; + } + + public async Task> GetPointsAsync(int jobId) + { + var db = await Db; + return await db.Table() + .Where(p => p.JobId == jobId) + .OrderBy(p => p.TimestampUtc) + .ToListAsync(); + } + + public async Task GetPointCountAsync(int jobId) + { + var db = await Db; + return await db.Table().Where(p => p.JobId == jobId).CountAsync(); + } + + public async Task DeletePointAsync(int pointId) + { + var db = await Db; + await db.DeleteAsync(pointId); + } +} diff --git a/FieldLogger/Services/DeviceConnectionManager.cs b/FieldLogger/Services/DeviceConnectionManager.cs new file mode 100644 index 0000000..d5f4e43 --- /dev/null +++ b/FieldLogger/Services/DeviceConnectionManager.cs @@ -0,0 +1,175 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using FieldLogger.Models; +using FieldLogger.Services.Ble; +using Microsoft.Extensions.Logging; + +namespace FieldLogger.Services; + +public enum ConnectionState { Disconnected, Connecting, Connected } + +/// +/// Owns the lifetime of both device connections: auto-connects to saved devices +/// at startup, reconnects with backoff when a connection drops, and exposes +/// bindable state for the UI. +/// +public sealed partial class DeviceConnectionManager : ObservableObject +{ + private static readonly TimeSpan[] RetryDelays = + [TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)]; + + private readonly UmReceiverService _locator; + private readonly MaglinkService _gps; + private readonly SettingsService _settings; + private readonly ILogger _logger; + + private CancellationTokenSource? _locatorCts; + private CancellationTokenSource? _gpsCts; + + [ObservableProperty] + private ConnectionState _locatorState; + + [ObservableProperty] + private ConnectionState _gpsState; + + [ObservableProperty] + private string? _locatorName; + + [ObservableProperty] + private string? _gpsName; + + public UmReceiverService Locator => _locator; + public MaglinkService Gps => _gps; + + public DeviceConnectionManager(UmReceiverService locator, MaglinkService gps, + SettingsService settings, ILogger logger) + { + _locator = locator; + _gps = gps; + _settings = settings; + _logger = logger; + + _locator.Disconnected += (_, _) => OnDeviceDropped(DeviceKind.Locator); + _gps.Disconnected += (_, _) => OnDeviceDropped(DeviceKind.RtkGps); + + LocatorName = settings.GetSavedDeviceName(DeviceKind.Locator); + GpsName = settings.GetSavedDeviceName(DeviceKind.RtkGps); + } + + /// True when a device of this kind has been selected at least once. + public bool HasSavedDevice(DeviceKind kind) => _settings.GetSavedDeviceId(kind) is not null; + + /// Kicks off auto-connect attempts for every saved device. Safe to call repeatedly. + public void Start() + { + if (HasSavedDevice(DeviceKind.Locator) && LocatorState == ConnectionState.Disconnected) + _ = ConnectLoopAsync(DeviceKind.Locator); + if (HasSavedDevice(DeviceKind.RtkGps) && GpsState == ConnectionState.Disconnected) + _ = ConnectLoopAsync(DeviceKind.RtkGps); + } + + /// Saves a newly selected device and connects to it, replacing any previous device. + public async Task UseDeviceAsync(DeviceKind kind, Guid id, string name) + { + CancelLoop(kind); + await (kind == DeviceKind.Locator ? _locator.DisconnectAsync() : _gps.DisconnectAsync()); + + _settings.SaveDevice(kind, id, name); + if (kind == DeviceKind.Locator) + LocatorName = name; + else + GpsName = name; + + _ = ConnectLoopAsync(kind); + } + + public async Task ForgetDeviceAsync(DeviceKind kind) + { + CancelLoop(kind); + await (kind == DeviceKind.Locator ? _locator.DisconnectAsync() : _gps.DisconnectAsync()); + _settings.ClearDevice(kind); + + if (kind == DeviceKind.Locator) + { + LocatorName = null; + LocatorState = ConnectionState.Disconnected; + } + else + { + GpsName = null; + GpsState = ConnectionState.Disconnected; + } + } + + private void OnDeviceDropped(DeviceKind kind) + { + _logger.LogInformation("{Kind} connection lost; scheduling reconnect", kind); + SetState(kind, ConnectionState.Disconnected); + _ = ConnectLoopAsync(kind); + } + + private async Task ConnectLoopAsync(DeviceKind kind) + { + var id = _settings.GetSavedDeviceId(kind); + if (id is null) + return; + + CancelLoop(kind); + var cts = new CancellationTokenSource(); + if (kind == DeviceKind.Locator) + _locatorCts = cts; + else + _gpsCts = cts; + + SetState(kind, ConnectionState.Connecting); + + for (var attempt = 0; !cts.Token.IsCancellationRequested; attempt++) + { + try + { + await BleScanner.EnsurePermissionsAsync(); + if (kind == DeviceKind.Locator) + await _locator.ConnectAsync(id.Value, cts.Token); + else + await _gps.ConnectAsync(id.Value, cts.Token); + + SetState(kind, ConnectionState.Connected); + _logger.LogInformation("{Kind} connected", kind); + return; + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "{Kind} connect attempt {Attempt} failed", kind, attempt + 1); + var delay = RetryDelays[Math.Min(attempt, RetryDelays.Length - 1)]; + try { await Task.Delay(delay, cts.Token); } + catch (OperationCanceledException) { return; } + } + } + } + + private void CancelLoop(DeviceKind kind) + { + var cts = kind == DeviceKind.Locator ? _locatorCts : _gpsCts; + cts?.Cancel(); + cts?.Dispose(); + if (kind == DeviceKind.Locator) + _locatorCts = null; + else + _gpsCts = null; + } + + private void SetState(DeviceKind kind, ConnectionState state) + { + // Property change notifications must fire on the UI thread for bindings. + MainThread.BeginInvokeOnMainThread(() => + { + if (kind == DeviceKind.Locator) + LocatorState = state; + else + GpsState = state; + }); + } +} diff --git a/FieldLogger/Services/MaglinkService.cs b/FieldLogger/Services/MaglinkService.cs new file mode 100644 index 0000000..47f1c2e --- /dev/null +++ b/FieldLogger/Services/MaglinkService.cs @@ -0,0 +1,98 @@ +using FieldLogger.Models; +using FieldLogger.Services.Ble; +using Microsoft.Extensions.Logging; + +namespace FieldLogger.Services; + +/// +/// Manages the connection to the Maglink (H11) RTK GNSS receiver: parses the +/// $GNPOS / $GNDEV custom NMEA stream and sends AT configuration commands. +/// +public sealed class MaglinkService : IAsyncDisposable +{ + public static readonly Guid ServiceUuid = Guid.Parse("0000fff0-0000-1000-8000-00805f9b34fb"); + public static readonly Guid NotifyUuid = Guid.Parse("0000fff2-0000-1000-8000-00805f9b34fb"); + public static readonly Guid WriteUuid = Guid.Parse("0000fff2-0000-1000-8000-00805f9b34fb"); + + private readonly BleSerialClient _client = new(); + private readonly ILogger _logger; + + public GnssFix? LatestFix { get; private set; } + public GnssDeviceInfo? DeviceInfo { get; private set; } + public bool IsConnected => _client.IsConnected; + public string? DeviceName => _client.DeviceName; + + public event EventHandler? FixReceived; + public event EventHandler? DeviceInfoReceived; + public event EventHandler? Disconnected; + + /// Device-name filter for scan results (ML-*). + public static bool IsMaglinkName(string? name) => + name is not null && name.StartsWith("ML-", StringComparison.OrdinalIgnoreCase); + + public MaglinkService(ILogger logger) + { + _logger = logger; + _client.LineReceived += OnLineReceived; + _client.Disconnected += (_, _) => Disconnected?.Invoke(this, EventArgs.Empty); + } + + public async Task ConnectAsync(Guid deviceId, CancellationToken cancellationToken = default) + { + await _client.ConnectAsync(deviceId, ServiceUuid, NotifyUuid, WriteUuid, cancellationToken); + + // Custom mode with GNPOS + GNDEV only - everything the app needs, minimal traffic. + await SendCommandAsync("AT+BT_OUT=SET,1,0,1,1,0,0,0,0,0,0", cancellationToken); + } + + /// Sends an AT command (terminator appended automatically). + public Task SendCommandAsync(string command, CancellationToken cancellationToken = default) + => _client.WriteLineAsync(command, cancellationToken); + + public Task DisconnectAsync() => _client.DisconnectAsync(); + + /// A fix received within the last few seconds, or null if the stream has gone stale. + public GnssFix? FreshFix(TimeSpan? maxAge = null) + { + var fix = LatestFix; + if (fix is null) + return null; + return DateTime.UtcNow - fix.ReceivedUtc <= (maxAge ?? TimeSpan.FromSeconds(5)) ? fix : null; + } + + private void OnLineReceived(object? sender, string line) + { + if (line.StartsWith("$GNPOS,", StringComparison.Ordinal)) + { + if (!NmeaSentence.VerifyChecksum(line)) + { + _logger.LogWarning("GNPOS checksum failed: {Line}", line); + return; + } + var fix = GnssFix.TryParse(line); + if (fix is not null) + { + LatestFix = fix; + FixReceived?.Invoke(this, fix); + } + } + else if (line.StartsWith("$GNDEV,", StringComparison.Ordinal)) + { + if (!NmeaSentence.VerifyChecksum(line)) + return; + var info = GnssDeviceInfo.TryParse(line); + if (info is not null) + { + DeviceInfo = info; + DeviceInfoReceived?.Invoke(this, info); + } + } + else + { + // AT command responses and anything else. + _logger.LogDebug("Maglink rx: {Line}", line); + } + } + + public ValueTask DisposeAsync() => _client.DisposeAsync(); +} diff --git a/FieldLogger/Services/PointLogger.cs b/FieldLogger/Services/PointLogger.cs new file mode 100644 index 0000000..2a3c433 --- /dev/null +++ b/FieldLogger/Services/PointLogger.cs @@ -0,0 +1,67 @@ +using FieldLogger.Models; +using FieldLogger.Services.Data; +using Microsoft.Extensions.Logging; + +namespace FieldLogger.Services; + +/// +/// Joins UM receiver log packets with the current GNSS fix and persists them +/// under the active job. Runs for the lifetime of the app. +/// +public sealed class PointLogger +{ + private readonly UmReceiverService _locator; + private readonly MaglinkService _gps; + private readonly AppDatabase _database; + private readonly SettingsService _settings; + private readonly ILogger _logger; + + /// Raised (on the UI thread) after a point is saved. + public event EventHandler? PointSaved; + + /// Raised when a packet arrives but no active job is selected, so the point was dropped. + public event EventHandler? PacketIgnoredNoJob; + + public PointLogger(UmReceiverService locator, MaglinkService gps, AppDatabase database, + SettingsService settings, ILogger logger) + { + _locator = locator; + _gps = gps; + _database = database; + _settings = settings; + _logger = logger; + + _locator.PacketReceived += OnPacketReceived; + } + + private void OnPacketReceived(object? sender, UmLogPacket packet) + { + _ = HandlePacketAsync(packet); + } + + private async Task HandlePacketAsync(UmLogPacket packet) + { + try + { + var jobId = _settings.ActiveJobId; + if (jobId is null) + { + _logger.LogWarning("Log packet received but no active job; point dropped"); + MainThread.BeginInvokeOnMainThread(() => PacketIgnoredNoJob?.Invoke(this, EventArgs.Empty)); + return; + } + + var fix = _gps.FreshFix(); + var point = LoggedPoint.From(jobId.Value, packet, _locator.DeviceInfo, fix, _gps.DeviceInfo); + await _database.AddPointAsync(point); + + _logger.LogInformation("Point {Id} saved to job {JobId} (gps valid: {GpsValid})", + point.Id, jobId, point.GpsValid); + MainThread.BeginInvokeOnMainThread(() => PointSaved?.Invoke(this, point)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save logged point"); + } + } +} diff --git a/FieldLogger/Services/SettingsService.cs b/FieldLogger/Services/SettingsService.cs new file mode 100644 index 0000000..ee30e34 --- /dev/null +++ b/FieldLogger/Services/SettingsService.cs @@ -0,0 +1,58 @@ +using FieldLogger.Models; + +namespace FieldLogger.Services; + +/// Persisted app settings: paired device identities, active job, map key. +public sealed class SettingsService +{ + private const string LocatorIdKey = "device.locator.id"; + private const string LocatorNameKey = "device.locator.name"; + private const string RtkIdKey = "device.rtk.id"; + private const string RtkNameKey = "device.rtk.name"; + private const string ActiveJobKey = "job.active.id"; + private const string MapsApiKeyKey = "maps.apikey"; + + public Guid? GetSavedDeviceId(DeviceKind kind) + { + var raw = Preferences.Default.Get(IdKey(kind), string.Empty); + return Guid.TryParse(raw, out var id) ? id : null; + } + + public string? GetSavedDeviceName(DeviceKind kind) + { + var name = Preferences.Default.Get(NameKey(kind), string.Empty); + return string.IsNullOrEmpty(name) ? null : name; + } + + public void SaveDevice(DeviceKind kind, Guid id, string name) + { + Preferences.Default.Set(IdKey(kind), id.ToString()); + Preferences.Default.Set(NameKey(kind), name); + } + + public void ClearDevice(DeviceKind kind) + { + Preferences.Default.Remove(IdKey(kind)); + Preferences.Default.Remove(NameKey(kind)); + } + + public int? ActiveJobId + { + get + { + var id = Preferences.Default.Get(ActiveJobKey, 0); + return id > 0 ? id : null; + } + set => Preferences.Default.Set(ActiveJobKey, value ?? 0); + } + + /// Google Maps JavaScript API key used by the Windows WebView map. + public string GoogleMapsApiKey + { + get => Preferences.Default.Get(MapsApiKeyKey, string.Empty); + set => Preferences.Default.Set(MapsApiKeyKey, value); + } + + private static string IdKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorIdKey : RtkIdKey; + private static string NameKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorNameKey : RtkNameKey; +} diff --git a/FieldLogger/Services/Sync/IMqttSyncService.cs b/FieldLogger/Services/Sync/IMqttSyncService.cs new file mode 100644 index 0000000..ba6ff63 --- /dev/null +++ b/FieldLogger/Services/Sync/IMqttSyncService.cs @@ -0,0 +1,27 @@ +using FieldLogger.Models; + +namespace FieldLogger.Services.Sync; + +/// +/// Placeholder for the future MQTT synchronization layer: +/// - subscribe to receive jobs configured on the server +/// - publish logged points as they are captured +/// - reconcile the Synced flags on and +/// Planned implementation: MQTTnet client against the configured broker. +/// +public interface IMqttSyncService +{ + bool IsConnected { get; } + Task ConnectAsync(CancellationToken cancellationToken = default); + Task DisconnectAsync(); + Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default); +} + +/// No-op stand-in until the MQTT backend exists. +public sealed class NullMqttSyncService : IMqttSyncService +{ + public bool IsConnected => false; + public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task DisconnectAsync() => Task.CompletedTask; + public Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default) => Task.CompletedTask; +} diff --git a/FieldLogger/Services/UmReceiverService.cs b/FieldLogger/Services/UmReceiverService.cs new file mode 100644 index 0000000..681805d --- /dev/null +++ b/FieldLogger/Services/UmReceiverService.cs @@ -0,0 +1,88 @@ +using FieldLogger.Models; +using FieldLogger.Services.Ble; +using Microsoft.Extensions.Logging; + +namespace FieldLogger.Services; + +/// +/// Manages the connection to an Underground Magnetics locating receiver and +/// its push-button data-logging (PBDL) protocol. +/// +public sealed class UmReceiverService : IAsyncDisposable +{ + // UM Receiver BLE External Logging API v1.2: + // serial port service with notify (RX) and write-no-response (TX) characteristics. + public static readonly Guid ServiceUuid = Guid.Parse("554d0000-261f-677e-a6f1-54c57aa996d4"); + public static readonly Guid NotifyUuid = Guid.Parse("554d0001-261f-677e-a6f1-54c57aa996d4"); + public static readonly Guid WriteUuid = Guid.Parse("554d0002-261f-677e-a6f1-54c57aa996d4"); + + private readonly BleSerialClient _client = new(); + private readonly ILogger _logger; + + public UmDeviceInfo? DeviceInfo { get; private set; } + public bool IsConnected => _client.IsConnected; + public string? DeviceName => _client.DeviceName; + + /// Raised when the operator presses the log button on the receiver. + public event EventHandler? PacketReceived; + public event EventHandler? DeviceInfoReceived; + public event EventHandler? Disconnected; + + public UmReceiverService(ILogger logger) + { + _logger = logger; + _client.LineReceived += OnLineReceived; + _client.Disconnected += (_, _) => Disconnected?.Invoke(this, EventArgs.Empty); + } + + /// Device-name filter for scan results (UMRX_* for most brands, DT100_* for Leica). + public static bool IsUmReceiverName(string? name) => + name is not null && + (name.StartsWith("UMRX", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("DT100", StringComparison.OrdinalIgnoreCase)); + + public async Task ConnectAsync(Guid deviceId, CancellationToken cancellationToken = default) + { + await _client.ConnectAsync(deviceId, ServiceUuid, NotifyUuid, WriteUuid, cancellationToken); + await EnableLoggingAsync(cancellationToken); + } + + /// Enables push-button data logging. The first enable triggers the info string. + public Task EnableLoggingAsync(CancellationToken cancellationToken = default) + => _client.WriteLineAsync("$UMPBDL,1", cancellationToken); + + public Task DisableLoggingAsync(CancellationToken cancellationToken = default) + => _client.WriteLineAsync("$UMPBDL,0", cancellationToken); + + public Task DisconnectAsync() => _client.DisconnectAsync(); + + private void OnLineReceived(object? sender, string line) + { + _logger.LogDebug("UM rx: {Line}", line); + + // Bare command acknowledgements. + var trimmed = line.Trim(); + if (trimmed.Equals("OK", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("ERROR", StringComparison.OrdinalIgnoreCase)) + return; + + var packet = UmLogPacket.TryParse(line); + if (packet is not null) + { + PacketReceived?.Invoke(this, packet); + return; + } + + var info = UmDeviceInfo.TryParse(line); + if (info is not null) + { + DeviceInfo = info; + DeviceInfoReceived?.Invoke(this, info); + return; + } + + _logger.LogWarning("UM receiver sent unrecognized line: {Line}", line); + } + + public ValueTask DisposeAsync() => _client.DisposeAsync(); +} diff --git a/FieldLogger/ViewModels/DeviceScanViewModel.cs b/FieldLogger/ViewModels/DeviceScanViewModel.cs new file mode 100644 index 0000000..64102a8 --- /dev/null +++ b/FieldLogger/ViewModels/DeviceScanViewModel.cs @@ -0,0 +1,107 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FieldLogger.Models; +using FieldLogger.Services; +using FieldLogger.Services.Ble; + +namespace FieldLogger.ViewModels; + +[QueryProperty(nameof(KindName), "kind")] +public sealed partial class DeviceScanViewModel : ObservableObject +{ + private readonly BleScanner _scanner; + private readonly DeviceConnectionManager _manager; + private CancellationTokenSource? _scanCts; + + [ObservableProperty] + private string _kindName = nameof(DeviceKind.Locator); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ScanButtonText))] + private bool _isScanning; + + [ObservableProperty] + private string _title = "Select Device"; + + [ObservableProperty] + private string _hint = ""; + + public ObservableCollection Devices { get; } = new(); + + public DeviceKind Kind => Enum.TryParse(KindName, out var k) ? k : DeviceKind.Locator; + + public string ScanButtonText => IsScanning ? "Scanning…" : "Scan Again"; + + public DeviceScanViewModel(BleScanner scanner, DeviceConnectionManager manager) + { + _scanner = scanner; + _manager = manager; + _scanner.DeviceDiscovered += OnDeviceDiscovered; + } + + partial void OnKindNameChanged(string value) + { + Title = Kind == DeviceKind.Locator ? "Select Locating Receiver" : "Select RTK GPS Receiver"; + Hint = Kind == DeviceKind.Locator + ? "Looking for UM receivers (UMRX / DT100). Make sure Bluetooth is enabled on the receiver." + : "Looking for Maglink RTK receivers. Make sure the receiver is powered on."; + } + + [RelayCommand] + public async Task ScanAsync() + { + if (IsScanning) + return; + + Devices.Clear(); + IsScanning = true; + _scanCts = new CancellationTokenSource(); + try + { + await _scanner.ScanAsync(TimeSpan.FromSeconds(12), _scanCts.Token); + } + catch (Exception ex) + { + await Shell.Current.DisplayAlert("Scan Failed", ex.Message, "OK"); + } + finally + { + IsScanning = false; + _scanCts?.Dispose(); + _scanCts = null; + } + } + + public void StopScan() => _scanCts?.Cancel(); + + [RelayCommand] + private async Task SelectAsync(DiscoveredDevice device) + { + StopScan(); + await _manager.UseDeviceAsync(Kind, device.Id, device.Name); + await Shell.Current.GoToAsync(".."); + } + + private void OnDeviceDiscovered(object? sender, DiscoveredDevice device) + { + // Filter by device name prefix based on device type. + if (Kind == DeviceKind.Locator && !UmReceiverService.IsUmReceiverName(device.Name)) + return; + if (Kind == DeviceKind.Maglink && !MaglinkService.IsMaglinkName(device.Name)) + return; + + MainThread.BeginInvokeOnMainThread(() => + { + var existing = Devices.FirstOrDefault(d => d.Id == device.Id); + if (existing is not null) + Devices.Remove(existing); + + // Keep the list sorted by signal strength. + var index = 0; + while (index < Devices.Count && Devices[index].Rssi >= device.Rssi) + index++; + Devices.Insert(index, device); + }); + } +} diff --git a/FieldLogger/ViewModels/HomeViewModel.cs b/FieldLogger/ViewModels/HomeViewModel.cs new file mode 100644 index 0000000..54a98cf --- /dev/null +++ b/FieldLogger/ViewModels/HomeViewModel.cs @@ -0,0 +1,149 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FieldLogger.Models; +using FieldLogger.Services; +using FieldLogger.Services.Data; + +namespace FieldLogger.ViewModels; + +public sealed partial class HomeViewModel : ObservableObject +{ + private readonly DeviceConnectionManager _manager; + private readonly MaglinkService _gps; + private readonly PointLogger _pointLogger; + private readonly AppDatabase _database; + private readonly SettingsService _settings; + + public DeviceConnectionManager Manager => _manager; + + [ObservableProperty] + private string _activeJobName = "No active job"; + + [ObservableProperty] + private bool _hasActiveJob; + + [ObservableProperty] + private int _pointCount; + + [ObservableProperty] + private string _fixSummary = "Waiting for GPS…"; + + [ObservableProperty] + private string _fixStatusLabel = "—"; + + [ObservableProperty] + private string _fixAccuracy = ""; + + public ObservableCollection RecentPoints { get; } = new(); + + public HomeViewModel(DeviceConnectionManager manager, MaglinkService gps, + PointLogger pointLogger, AppDatabase database, SettingsService settings) + { + _manager = manager; + _gps = gps; + _pointLogger = pointLogger; + _database = database; + _settings = settings; + + _gps.FixReceived += OnFixReceived; + _pointLogger.PointSaved += OnPointSaved; + _pointLogger.PacketIgnoredNoJob += OnPacketIgnoredNoJob; + } + + /// Called from the page's OnAppearing. + public async Task InitializeAsync() + { + // First launch: nothing saved yet, send the user to device selection. + if (!_manager.HasSavedDevice(DeviceKind.Locator) && !_manager.HasSavedDevice(DeviceKind.RtkGps)) + { + await Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}"); + return; + } + + _manager.Start(); + await RefreshJobAsync(); + } + + public async Task RefreshJobAsync() + { + var jobId = _settings.ActiveJobId; + if (jobId is null) + { + HasActiveJob = false; + ActiveJobName = "No active job"; + PointCount = 0; + RecentPoints.Clear(); + return; + } + + var job = await _database.GetJobAsync(jobId.Value); + if (job is null) + { + _settings.ActiveJobId = null; + await RefreshJobAsync(); + return; + } + + HasActiveJob = true; + ActiveJobName = job.Name; + PointCount = await _database.GetPointCountAsync(job.Id); + + var points = await _database.GetPointsAsync(job.Id); + RecentPoints.Clear(); + foreach (var p in points.OrderByDescending(p => p.TimestampUtc).Take(10)) + RecentPoints.Add(p); + } + + [RelayCommand] + private async Task NewJobAsync() + { + var name = await Shell.Current.DisplayPromptAsync("New Job", "Job name:", + placeholder: $"Job {DateTime.Now:yyyy-MM-dd}"); + if (string.IsNullOrWhiteSpace(name)) + return; + + var job = await _database.CreateJobAsync(name.Trim()); + _settings.ActiveJobId = job.Id; + await RefreshJobAsync(); + } + + [RelayCommand] + private Task SelectJobAsync() => Shell.Current.GoToAsync("//jobs"); + + [RelayCommand] + private Task SelectLocatorAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}"); + + [RelayCommand] + private Task SelectGpsAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.RtkGps}"); + + [RelayCommand] + private Task ViewMapAsync() => Shell.Current.GoToAsync("//map"); + + private void OnFixReceived(object? sender, GnssFix fix) + { + MainThread.BeginInvokeOnMainThread(() => + { + FixStatusLabel = fix.StatusLabel; + FixSummary = fix.HasPosition + ? $"{fix.Latitude:F8}, {fix.Longitude:F8} · {fix.AltitudeCorrected:F2} m" + : "No position"; + FixAccuracy = $"H ±{fix.Hrms:F3} m · V ±{fix.Vrms:F3} m · {fix.SatellitesUsed} sats · batt {fix.BatteryPercent}%"; + }); + } + + private void OnPointSaved(object? sender, LoggedPoint point) + { + RecentPoints.Insert(0, point); + while (RecentPoints.Count > 10) + RecentPoints.RemoveAt(RecentPoints.Count - 1); + PointCount++; + } + + private async void OnPacketIgnoredNoJob(object? sender, EventArgs e) + { + await Shell.Current.DisplayAlert("No Active Job", + "A point was logged on the receiver, but no job is active so it was not saved. Create or select a job first.", + "OK"); + } +} diff --git a/FieldLogger/ViewModels/JobDetailViewModel.cs b/FieldLogger/ViewModels/JobDetailViewModel.cs new file mode 100644 index 0000000..c76bb37 --- /dev/null +++ b/FieldLogger/ViewModels/JobDetailViewModel.cs @@ -0,0 +1,59 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FieldLogger.Models; +using FieldLogger.Services.Data; + +namespace FieldLogger.ViewModels; + +[QueryProperty(nameof(JobId), "jobId")] +public sealed partial class JobDetailViewModel : ObservableObject +{ + private readonly AppDatabase _database; + + [ObservableProperty] + private int _jobId; + + [ObservableProperty] + private string _jobName = ""; + + [ObservableProperty] + private int _pointCount; + + public ObservableCollection Points { get; } = new(); + + public JobDetailViewModel(AppDatabase database) + { + _database = database; + } + + public async Task RefreshAsync() + { + var job = await _database.GetJobAsync(JobId); + if (job is null) + return; + + JobName = job.Name; + var points = await _database.GetPointsAsync(JobId); + PointCount = points.Count; + Points.Clear(); + foreach (var p in points) + Points.Add(p); + } + + [RelayCommand] + private Task ViewMapAsync() => Shell.Current.GoToAsync($"//map?jobId={JobId}"); + + [RelayCommand] + private async Task DeletePointAsync(LoggedPoint point) + { + var confirmed = await Shell.Current.DisplayAlert("Delete Point", + $"Delete point #{point.Id} logged {point.TimestampUtc.ToLocalTime():g}?", "Delete", "Cancel"); + if (!confirmed) + return; + + await _database.DeletePointAsync(point.Id); + Points.Remove(point); + PointCount = Points.Count; + } +} diff --git a/FieldLogger/ViewModels/JobsViewModel.cs b/FieldLogger/ViewModels/JobsViewModel.cs new file mode 100644 index 0000000..2adcaa1 --- /dev/null +++ b/FieldLogger/ViewModels/JobsViewModel.cs @@ -0,0 +1,104 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FieldLogger.Models; +using FieldLogger.Services; +using FieldLogger.Services.Data; + +namespace FieldLogger.ViewModels; + +public sealed partial class JobListItem : ObservableObject +{ + public required Job Job { get; init; } + + [ObservableProperty] + private int _pointCount; + + [ObservableProperty] + private bool _isActive; + + public string Name => Job.Name; + public string CreatedLabel => Job.CreatedUtc.ToLocalTime().ToString("g"); +} + +public sealed partial class JobsViewModel : ObservableObject +{ + private readonly AppDatabase _database; + private readonly SettingsService _settings; + + public ObservableCollection Jobs { get; } = new(); + + [ObservableProperty] + private bool _isBusy; + + public JobsViewModel(AppDatabase database, SettingsService settings) + { + _database = database; + _settings = settings; + } + + public async Task RefreshAsync() + { + IsBusy = true; + try + { + var jobs = await _database.GetJobsAsync(); + var activeId = _settings.ActiveJobId; + + Jobs.Clear(); + foreach (var job in jobs) + { + Jobs.Add(new JobListItem + { + Job = job, + PointCount = await _database.GetPointCountAsync(job.Id), + IsActive = job.Id == activeId, + }); + } + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task NewJobAsync() + { + var name = await Shell.Current.DisplayPromptAsync("New Job", "Job name:", + placeholder: $"Job {DateTime.Now:yyyy-MM-dd}"); + if (string.IsNullOrWhiteSpace(name)) + return; + + var job = await _database.CreateJobAsync(name.Trim()); + _settings.ActiveJobId = job.Id; + await RefreshAsync(); + } + + [RelayCommand] + private async Task SetActiveAsync(JobListItem item) + { + _settings.ActiveJobId = item.Job.Id; + foreach (var j in Jobs) + j.IsActive = j.Job.Id == item.Job.Id; + await Task.CompletedTask; + } + + [RelayCommand] + private Task OpenAsync(JobListItem item) + => Shell.Current.GoToAsync($"jobdetail?jobId={item.Job.Id}"); + + [RelayCommand] + private async Task DeleteAsync(JobListItem item) + { + var confirmed = await Shell.Current.DisplayAlert("Delete Job", + $"Delete \"{item.Job.Name}\" and its {item.PointCount} logged points?", "Delete", "Cancel"); + if (!confirmed) + return; + + await _database.DeleteJobAsync(item.Job.Id); + if (_settings.ActiveJobId == item.Job.Id) + _settings.ActiveJobId = null; + await RefreshAsync(); + } +} diff --git a/FieldLogger/ViewModels/MapViewModel.cs b/FieldLogger/ViewModels/MapViewModel.cs new file mode 100644 index 0000000..9149588 --- /dev/null +++ b/FieldLogger/ViewModels/MapViewModel.cs @@ -0,0 +1,53 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using FieldLogger.Models; +using FieldLogger.Services; +using FieldLogger.Services.Data; + +namespace FieldLogger.ViewModels; + +[QueryProperty(nameof(JobId), "jobId")] +public sealed partial class MapViewModel : ObservableObject +{ + private readonly AppDatabase _database; + private readonly SettingsService _settings; + + /// Job to display; 0 means "use the active job". + [ObservableProperty] + private int _jobId; + + [ObservableProperty] + private string _jobName = ""; + + [ObservableProperty] + private string _statusText = ""; + + public SettingsService Settings => _settings; + + public MapViewModel(AppDatabase database, SettingsService settings) + { + _database = database; + _settings = settings; + } + + /// Loads the points to plot (only points with a valid GNSS position). + public async Task> LoadPointsAsync() + { + var jobId = JobId > 0 ? JobId : _settings.ActiveJobId; + if (jobId is null) + { + JobName = ""; + StatusText = "No job selected. Create or activate a job to see its points."; + return new List(); + } + + var job = await _database.GetJobAsync(jobId.Value); + JobName = job?.Name ?? ""; + + var all = await _database.GetPointsAsync(jobId.Value); + var plottable = all.Where(p => p.GpsValid).ToList(); + StatusText = plottable.Count == all.Count + ? $"{JobName}: {all.Count} points" + : $"{JobName}: {plottable.Count} of {all.Count} points have GPS positions"; + return plottable; + } +} diff --git a/FieldLogger/ViewModels/SettingsViewModel.cs b/FieldLogger/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..5ac61e6 --- /dev/null +++ b/FieldLogger/ViewModels/SettingsViewModel.cs @@ -0,0 +1,52 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FieldLogger.Models; +using FieldLogger.Services; + +namespace FieldLogger.ViewModels; + +public sealed partial class SettingsViewModel : ObservableObject +{ + private readonly DeviceConnectionManager _manager; + private readonly SettingsService _settings; + + public DeviceConnectionManager Manager => _manager; + + [ObservableProperty] + private string _mapsApiKey = ""; + + public string AppVersion => AppInfo.Current.VersionString; + + public SettingsViewModel(DeviceConnectionManager manager, SettingsService settings) + { + _manager = manager; + _settings = settings; + MapsApiKey = settings.GoogleMapsApiKey; + } + + partial void OnMapsApiKeyChanged(string value) => _settings.GoogleMapsApiKey = value.Trim(); + + [RelayCommand] + private Task ChangeLocatorAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}"); + + [RelayCommand] + private Task ChangeGpsAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.RtkGps}"); + + [RelayCommand] + private async Task ForgetLocatorAsync() + { + if (await ConfirmForgetAsync(_manager.LocatorName ?? "locating receiver")) + await _manager.ForgetDeviceAsync(DeviceKind.Locator); + } + + [RelayCommand] + private async Task ForgetGpsAsync() + { + if (await ConfirmForgetAsync(_manager.GpsName ?? "RTK GPS receiver")) + await _manager.ForgetDeviceAsync(DeviceKind.RtkGps); + } + + private static Task ConfirmForgetAsync(string name) => + Shell.Current.DisplayAlert("Forget Device", + $"Forget \"{name}\"? The app will no longer auto-connect to it.", "Forget", "Cancel"); +} diff --git a/FieldLogger/Views/DeviceScanPage.xaml b/FieldLogger/Views/DeviceScanPage.xaml new file mode 100644 index 0000000..3acb92f --- /dev/null +++ b/FieldLogger/Views/DeviceScanPage.xaml @@ -0,0 +1,44 @@ + + + + +