Initial commit: FieldLogger MAUI app with Maglink BLE support

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

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

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

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(git init)",
"Bash(git add .)"
],
"deny": [],
"ask": []
}
}

484
.gitignore vendored Normal file
View File

@@ -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

34
FieldLogger.sln Normal file
View File

@@ -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

21
FieldLogger/App.xaml Normal file
View File

@@ -0,0 +1,21 @@
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:FieldLogger"
xmlns:conv="clr-namespace:FieldLogger.Converters"
x:Class="FieldLogger.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
<conv:ConnectionStateToColorConverter x:Key="StateColor" />
<conv:ConnectionStateToTextConverter x:Key="StateText" />
<conv:UtilityToNameConverter x:Key="UtilityName" />
<conv:FixStatusToNameConverter x:Key="FixStatusName" />
<conv:InvertBoolConverter x:Key="InvertBool" />
</ResourceDictionary>
</Application.Resources>
</Application>

14
FieldLogger/App.xaml.cs Normal file
View File

@@ -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());
}
}

16
FieldLogger/AppShell.xaml Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="FieldLogger.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:FieldLogger.Views"
Title="Field Logger">
<TabBar>
<ShellContent Title="Home" Route="home" ContentTemplate="{DataTemplate views:HomePage}" />
<ShellContent Title="Jobs" Route="jobs" ContentTemplate="{DataTemplate views:JobsPage}" />
<ShellContent Title="Map" Route="map" ContentTemplate="{DataTemplate views:MapPage}" />
<ShellContent Title="Settings" Route="settings" ContentTemplate="{DataTemplate views:SettingsPage}" />
</TabBar>
</Shell>

View File

@@ -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));
}
}

View File

@@ -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();
}

View File

@@ -0,0 +1,82 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.19041.0</TargetFrameworks>
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
<!-- <TargetFrameworks>$(TargetFrameworks);net9.0-tizen</TargetFrameworks> -->
<!-- Note for MacCatalyst:
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
When specifying both architectures, use the plural <RuntimeIdentifiers> instead of the singular <RuntimeIdentifier>.
The Mac App Store will NOT accept apps with ONLY maccatalyst-arm64 indicated;
either BOTH runtimes must be indicated or ONLY macatalyst-x64. -->
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
<OutputType>Exe</OutputType>
<RootNamespace>FieldLogger</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- MVVMTK0045: [ObservableProperty] on fields is not WinRT-AOT compatible. This app is not
published with native AOT on Windows; revisit if that changes (needs LangVersion=preview
partial properties). -->
<NoWarn>$(NoWarn);MVVMTK0045</NoWarn>
<!-- Display name -->
<ApplicationTitle>Field Logger</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>com.undergroundmagnetics.fieldlogger</ApplicationId>
<!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
</PropertyGroup>
<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Plugin.BLE" Version="3.1.0" />
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
</ItemGroup>
<!-- Native map control (Google Maps on Android, Apple Maps on iOS/macOS). Not available on Windows,
where MapPage falls back to a Google Maps JavaScript WebView instead. -->
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<PackageReference Include="Microsoft.Maui.Controls.Maps" Version="$(MauiVersion)" />
</ItemGroup>
</Project>

View File

@@ -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")]

View File

@@ -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<App>()
#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<SettingsService>();
builder.Services.AddSingleton<AppDatabase>();
builder.Services.AddSingleton<BleScanner>();
builder.Services.AddSingleton<UmReceiverService>();
builder.Services.AddSingleton<MaglinkService>();
builder.Services.AddSingleton<DeviceConnectionManager>();
builder.Services.AddSingleton<PointLogger>();
builder.Services.AddSingleton<IMqttSyncService, NullMqttSyncService>();
// View models
builder.Services.AddSingleton<HomeViewModel>();
builder.Services.AddSingleton<JobsViewModel>();
builder.Services.AddSingleton<MapViewModel>();
builder.Services.AddSingleton<SettingsViewModel>();
builder.Services.AddTransient<DeviceScanViewModel>();
builder.Services.AddTransient<JobDetailViewModel>();
// Pages
builder.Services.AddSingleton<HomePage>();
builder.Services.AddSingleton<JobsPage>();
builder.Services.AddSingleton<MapPage>();
builder.Services.AddSingleton<SettingsPage>();
builder.Services.AddTransient<DeviceScanPage>();
builder.Services.AddTransient<JobDetailPage>();
var app = builder.Build();
// Instantiate the point logger so it listens for receiver packets from startup.
_ = app.Services.GetRequiredService<PointLogger>();
return app;
}
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true">
<!-- Google Maps API key: replace with your key from https://console.cloud.google.com (Maps SDK for Android) -->
<meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_GOOGLE_MAPS_ANDROID_API_KEY" />
</application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- BLE: Android 12+ (API 31) runtime permissions -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- BLE: Android 11 and below -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
</manifest>

View File

@@ -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
{
}

View File

@@ -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();
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>

View File

@@ -0,0 +1,9 @@
using Foundation;
namespace FieldLogger;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
<dict>
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Required for CoreBluetooth access to the UM receiver and Maglink RTK devices. -->
<key>com.apple.security.device.bluetooth</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The Mac App Store requires you specify if the app uses encryption. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
<!-- Please indicate <true/> or <false/> here. -->
<!-- Specify the category for your app here. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
<!-- <key>LSApplicationCategoryType</key> -->
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
<key>UIDeviceFamily</key>
<array>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Field Logger uses Bluetooth to connect to your Underground Magnetics locating receiver and Maglink RTK GPS receiver.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Field Logger uses your location to display logged points on the map.</string>
</dict>
</plist>

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="maui-application-id-placeholder" version="0.0.0" api-version="9" xmlns="http://tizen.org/ns/packages">
<profile name="common" />
<ui-application appid="maui-application-id-placeholder" exec="FieldLogger.dll" multiple="false" nodisplay="false" taskmanage="true" type="dotnet" launch_mode="single">
<label>maui-application-title-placeholder</label>
<icon>maui-appicon-placeholder</icon>
<metadata key="http://tizen.org/metadata/prefer_dotnet_aot" value="true" />
</ui-application>
<shortcut-list />
<privileges>
<privilege>http://tizen.org/privilege/internet</privilege>
</privileges>
<dependencies />
<provides-appdefined-privileges />
</manifest>

View File

@@ -0,0 +1,8 @@
<maui:MauiWinUIApplication
x:Class="FieldLogger.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:FieldLogger.WinUI">
</maui:MauiWinUIApplication>

View File

@@ -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;
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// 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().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
<mp:PhoneIdentity PhoneProductId="0E66E150-3452-4D9B-BA88-43820FA51A97" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<DeviceCapability Name="bluetooth" />
</Capabilities>
</Package>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="FieldLogger.WinUI.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,9 @@
using Foundation;
namespace FieldLogger;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Field Logger uses Bluetooth to connect to your Underground Magnetics locating receiver and Maglink RTK GPS receiver.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Field Logger uses your location to display logged points on the map.</string>
</dict>
</plist>

View File

@@ -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));
}
}

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
You are responsible for adding extra entries as needed for your application.
More information: https://aka.ms/maui-privacy-manifest
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<!--
The entry below is only needed when you're using the Preferences API in your app.
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict> -->
</array>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
{
"profiles": {
"Windows Machine": {
"commandName": "Project",
"nativeDebugging": false
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -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`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
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();
}

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body, #map { height: 100%; margin: 0; padding: 0; }
</style>
</head>
<body>
<div id="map"></div>
<script>
let map;
let markers = [];
let infoWindow;
function initMap() {
map = new google.maps.Map(document.getElementById('map'), {
center: { lat: 39.5, lng: -98.35 },
zoom: 4,
mapTypeId: 'hybrid'
});
infoWindow = new google.maps.InfoWindow();
}
// Called from the app with an array of point objects.
function setPoints(points) {
markers.forEach(m => m.setMap(null));
markers = [];
if (!map || !points || points.length === 0) return;
const bounds = new google.maps.LatLngBounds();
points.forEach(p => {
const pos = { lat: p.lat, lng: p.lng };
const marker = new google.maps.Marker({
position: pos,
map: map,
title: '#' + p.id + ' ' + p.utility
});
marker.addListener('click', () => {
infoWindow.setContent(
'<b>#' + p.id + ' &middot; ' + p.utility + '</b><br/>' +
'Depth: ' + p.depth + ' &middot; Current: ' + p.current + '<br/>' +
'Frequency: ' + p.frequency + ' Hz<br/>' +
'Fix: ' + p.fix + ' (&plusmn;' + p.hrms.toFixed(3) + ' m)<br/>' +
p.time);
infoWindow.open(map, marker);
});
markers.push(marker);
bounds.extend(pos);
});
if (points.length === 1) {
map.setCenter(bounds.getCenter());
map.setZoom(19);
} else {
map.fitBounds(bounds, 60);
}
}
</script>
<script async
src="https://maps.googleapis.com/maps/api/js?key=%%GOOGLE_MAPS_API_KEY%%&callback=initMap"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<!-- Note: For Android please see also Platforms\Android\Resources\values\colors.xml -->
<Color x:Key="Primary">#512BD4</Color>
<Color x:Key="PrimaryDark">#ac99ea</Color>
<Color x:Key="PrimaryDarkText">#242424</Color>
<Color x:Key="Secondary">#DFD8F7</Color>
<Color x:Key="SecondaryDarkText">#9880e5</Color>
<Color x:Key="Tertiary">#2B0B98</Color>
<Color x:Key="White">White</Color>
<Color x:Key="Black">Black</Color>
<Color x:Key="Magenta">#D600AA</Color>
<Color x:Key="MidnightBlue">#190649</Color>
<Color x:Key="OffBlack">#1f1f1f</Color>
<Color x:Key="Gray100">#E1E1E1</Color>
<Color x:Key="Gray200">#C8C8C8</Color>
<Color x:Key="Gray300">#ACACAC</Color>
<Color x:Key="Gray400">#919191</Color>
<Color x:Key="Gray500">#6E6E6E</Color>
<Color x:Key="Gray600">#404040</Color>
<Color x:Key="Gray900">#212121</Color>
<Color x:Key="Gray950">#141414</Color>
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/>
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/>
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/>
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/>
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/>
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/>
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/>
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/>
</ResourceDictionary>

View File

@@ -0,0 +1,444 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="IndicatorView">
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
</Style>
<Style TargetType="Border">
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="StrokeShape" Value="Rectangle"/>
<Setter Property="StrokeThickness" Value="1"/>
</Style>
<Style TargetType="BoxView">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="Button">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14,10"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="DatePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Editor">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Entry">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ImageButton">
<Setter Property="Opacity" Value="1" />
<Setter Property="BorderColor" Value="Transparent"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="0"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Opacity" Value="0.5" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Span">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="Label" x:Key="Headline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="32" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="Label" x:Key="SubHeadline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="24" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="ListView">
<Setter Property="SeparatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="RefreshControlColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="Picker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RadioButton">
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RefreshView">
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="SearchBar">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SearchHandler">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Shadow">
<Setter Property="Radius" Value="15" />
<Setter Property="Opacity" Value="0.5" />
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
<Setter Property="Offset" Value="10,10" />
</Style>
<Style TargetType="Slider">
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SwipeItem">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
</Style>
<Style TargetType="Switch">
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="ThumbColor" Value="{StaticResource White}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="On">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Off">
<VisualState.Setters>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="TimePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<!--
<Style TargetType="TitleBar">
<Setter Property="MinimumHeightRequest" Value="32"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="TitleActiveStates">
<VisualState x:Name="TitleBarTitleActive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="TitleBarTitleInactive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
-->
<Style TargetType="Page" ApplyToDerivedTypes="True">
<Setter Property="Padding" Value="0"/>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
</Style>
<Style TargetType="Shell" ApplyToDerivedTypes="True">
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
<Setter Property="Shell.NavBarHasShadow" Value="False" />
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="TabbedPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,81 @@
using Plugin.BLE;
using Plugin.BLE.Abstractions.Contracts;
using Plugin.BLE.Abstractions.EventArgs;
namespace FieldLogger.Services.Ble;
/// <summary>A device found during a BLE scan.</summary>
public sealed record DiscoveredDevice(Guid Id, string Name, int Rssi);
/// <summary>Thin scan wrapper over Plugin.BLE with per-platform permission handling.</summary>
public sealed class BleScanner
{
private readonly IAdapter _adapter = CrossBluetoothLE.Current.Adapter;
public event EventHandler<DiscoveredDevice>? DeviceDiscovered;
public bool IsScanning => _adapter.IsScanning;
/// <summary>
/// Scans for BLE devices for the given duration, raising DeviceDiscovered as devices appear.
/// Only named devices are reported.
/// </summary>
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();
}
}
/// <summary>Requests the runtime permissions BLE scanning needs (Android only; no-op elsewhere).</summary>
public static async Task EnsurePermissionsAsync()
{
#if ANDROID
if (OperatingSystem.IsAndroidVersionAtLeast(31))
{
var status = await Permissions.RequestAsync<Permissions.Bluetooth>();
if (status != PermissionStatus.Granted)
throw new PermissionException("Bluetooth permission was denied.");
}
else
{
var status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted)
throw new PermissionException("Location permission (required for BLE scanning) was denied.");
}
#else
await Task.CompletedTask;
#endif
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<string>? 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;
}
/// <summary>
/// Connects to a known device by id, discovers the given service, and
/// enables notifications. Throws on failure.
/// </summary>
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;
}
}
/// <summary>Writes an ASCII string followed by CR+LF to the device.</summary>
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<string> 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);
}
}

View File

@@ -0,0 +1,90 @@
using FieldLogger.Models;
using SQLite;
namespace FieldLogger.Services.Data;
/// <summary>Local SQLite store for jobs and logged points.</summary>
public sealed class AppDatabase
{
private readonly Lazy<Task<SQLiteAsyncConnection>> _connection;
public AppDatabase()
{
_connection = new Lazy<Task<SQLiteAsyncConnection>>(async () =>
{
var path = Path.Combine(FileSystem.AppDataDirectory, "fieldlogger.db3");
var db = new SQLiteAsyncConnection(path,
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
await db.CreateTableAsync<Job>();
await db.CreateTableAsync<LoggedPoint>();
return db;
});
}
private Task<SQLiteAsyncConnection> Db => _connection.Value;
// ---- Jobs ----
public async Task<List<Job>> GetJobsAsync()
{
var db = await Db;
return await db.Table<Job>().OrderByDescending(j => j.CreatedUtc).ToListAsync();
}
public async Task<Job?> GetJobAsync(int id)
{
var db = await Db;
return await db.Table<Job>().Where(j => j.Id == id).FirstOrDefaultAsync();
}
public async Task<Job> 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<LoggedPoint>().DeleteAsync(p => p.JobId == jobId);
await db.DeleteAsync<Job>(jobId);
}
// ---- Points ----
public async Task<int> AddPointAsync(LoggedPoint point)
{
var db = await Db;
await db.InsertAsync(point);
return point.Id;
}
public async Task<List<LoggedPoint>> GetPointsAsync(int jobId)
{
var db = await Db;
return await db.Table<LoggedPoint>()
.Where(p => p.JobId == jobId)
.OrderBy(p => p.TimestampUtc)
.ToListAsync();
}
public async Task<int> GetPointCountAsync(int jobId)
{
var db = await Db;
return await db.Table<LoggedPoint>().Where(p => p.JobId == jobId).CountAsync();
}
public async Task DeletePointAsync(int pointId)
{
var db = await Db;
await db.DeleteAsync<LoggedPoint>(pointId);
}
}

View File

@@ -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 }
/// <summary>
/// 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.
/// </summary>
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<DeviceConnectionManager> _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<DeviceConnectionManager> 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);
}
/// <summary>True when a device of this kind has been selected at least once.</summary>
public bool HasSavedDevice(DeviceKind kind) => _settings.GetSavedDeviceId(kind) is not null;
/// <summary>Kicks off auto-connect attempts for every saved device. Safe to call repeatedly.</summary>
public void Start()
{
if (HasSavedDevice(DeviceKind.Locator) && LocatorState == ConnectionState.Disconnected)
_ = ConnectLoopAsync(DeviceKind.Locator);
if (HasSavedDevice(DeviceKind.RtkGps) && GpsState == ConnectionState.Disconnected)
_ = ConnectLoopAsync(DeviceKind.RtkGps);
}
/// <summary>Saves a newly selected device and connects to it, replacing any previous device.</summary>
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;
});
}
}

View File

@@ -0,0 +1,98 @@
using FieldLogger.Models;
using FieldLogger.Services.Ble;
using Microsoft.Extensions.Logging;
namespace FieldLogger.Services;
/// <summary>
/// Manages the connection to the Maglink (H11) RTK GNSS receiver: parses the
/// $GNPOS / $GNDEV custom NMEA stream and sends AT configuration commands.
/// </summary>
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<MaglinkService> _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<GnssFix>? FixReceived;
public event EventHandler<GnssDeviceInfo>? DeviceInfoReceived;
public event EventHandler? Disconnected;
/// <summary>Device-name filter for scan results (ML-*).</summary>
public static bool IsMaglinkName(string? name) =>
name is not null && name.StartsWith("ML-", StringComparison.OrdinalIgnoreCase);
public MaglinkService(ILogger<MaglinkService> 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);
}
/// <summary>Sends an AT command (terminator appended automatically).</summary>
public Task SendCommandAsync(string command, CancellationToken cancellationToken = default)
=> _client.WriteLineAsync(command, cancellationToken);
public Task DisconnectAsync() => _client.DisconnectAsync();
/// <summary>A fix received within the last few seconds, or null if the stream has gone stale.</summary>
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();
}

View File

@@ -0,0 +1,67 @@
using FieldLogger.Models;
using FieldLogger.Services.Data;
using Microsoft.Extensions.Logging;
namespace FieldLogger.Services;
/// <summary>
/// Joins UM receiver log packets with the current GNSS fix and persists them
/// under the active job. Runs for the lifetime of the app.
/// </summary>
public sealed class PointLogger
{
private readonly UmReceiverService _locator;
private readonly MaglinkService _gps;
private readonly AppDatabase _database;
private readonly SettingsService _settings;
private readonly ILogger<PointLogger> _logger;
/// <summary>Raised (on the UI thread) after a point is saved.</summary>
public event EventHandler<LoggedPoint>? PointSaved;
/// <summary>Raised when a packet arrives but no active job is selected, so the point was dropped.</summary>
public event EventHandler? PacketIgnoredNoJob;
public PointLogger(UmReceiverService locator, MaglinkService gps, AppDatabase database,
SettingsService settings, ILogger<PointLogger> 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");
}
}
}

View File

@@ -0,0 +1,58 @@
using FieldLogger.Models;
namespace FieldLogger.Services;
/// <summary>Persisted app settings: paired device identities, active job, map key.</summary>
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);
}
/// <summary>Google Maps JavaScript API key used by the Windows WebView map.</summary>
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;
}

View File

@@ -0,0 +1,27 @@
using FieldLogger.Models;
namespace FieldLogger.Services.Sync;
/// <summary>
/// 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 <see cref="Job"/> and <see cref="LoggedPoint"/>
/// Planned implementation: MQTTnet client against the configured broker.
/// </summary>
public interface IMqttSyncService
{
bool IsConnected { get; }
Task ConnectAsync(CancellationToken cancellationToken = default);
Task DisconnectAsync();
Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default);
}
/// <summary>No-op stand-in until the MQTT backend exists.</summary>
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;
}

View File

@@ -0,0 +1,88 @@
using FieldLogger.Models;
using FieldLogger.Services.Ble;
using Microsoft.Extensions.Logging;
namespace FieldLogger.Services;
/// <summary>
/// Manages the connection to an Underground Magnetics locating receiver and
/// its push-button data-logging (PBDL) protocol.
/// </summary>
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<UmReceiverService> _logger;
public UmDeviceInfo? DeviceInfo { get; private set; }
public bool IsConnected => _client.IsConnected;
public string? DeviceName => _client.DeviceName;
/// <summary>Raised when the operator presses the log button on the receiver.</summary>
public event EventHandler<UmLogPacket>? PacketReceived;
public event EventHandler<UmDeviceInfo>? DeviceInfoReceived;
public event EventHandler? Disconnected;
public UmReceiverService(ILogger<UmReceiverService> logger)
{
_logger = logger;
_client.LineReceived += OnLineReceived;
_client.Disconnected += (_, _) => Disconnected?.Invoke(this, EventArgs.Empty);
}
/// <summary>Device-name filter for scan results (UMRX_* for most brands, DT100_* for Leica).</summary>
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);
}
/// <summary>Enables push-button data logging. The first enable triggers the info string.</summary>
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();
}

View File

@@ -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<DiscoveredDevice> Devices { get; } = new();
public DeviceKind Kind => Enum.TryParse<DeviceKind>(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);
});
}
}

View File

@@ -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<LoggedPoint> 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;
}
/// <summary>Called from the page's OnAppearing.</summary>
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");
}
}

View File

@@ -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<LoggedPoint> 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;
}
}

View File

@@ -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<JobListItem> 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();
}
}

View File

@@ -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;
/// <summary>Job to display; 0 means "use the active job".</summary>
[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;
}
/// <summary>Loads the points to plot (only points with a valid GNSS position).</summary>
public async Task<List<LoggedPoint>> 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<LoggedPoint>();
}
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;
}
}

View File

@@ -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<bool> ConfirmForgetAsync(string name) =>
Shell.Current.DisplayAlert("Forget Device",
$"Forget \"{name}\"? The app will no longer auto-connect to it.", "Forget", "Cancel");
}

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
xmlns:ble="clr-namespace:FieldLogger.Services.Ble"
x:Class="FieldLogger.Views.DeviceScanPage"
x:DataType="vm:DeviceScanViewModel"
Title="{Binding Title}">
<Grid RowDefinitions="Auto,Auto,*" Padding="16" RowSpacing="12">
<Label Grid.Row="0" Text="{Binding Hint}" FontSize="13" TextColor="Gray" />
<HorizontalStackLayout Grid.Row="1" Spacing="12">
<Button Text="{Binding ScanButtonText}" Command="{Binding ScanCommand}" IsEnabled="{Binding IsScanning, Converter={StaticResource InvertBool}}" />
<ActivityIndicator IsRunning="{Binding IsScanning}" VerticalOptions="Center" />
</HorizontalStackLayout>
<CollectionView Grid.Row="2" ItemsSource="{Binding Devices}">
<CollectionView.EmptyView>
<Label Text="No devices found yet." TextColor="Gray" Margin="8" />
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="ble:DiscoveredDevice">
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}"
StrokeShape="RoundRectangle 10" Padding="12" Margin="0,4">
<Border.GestureRecognizers>
<TapGestureRecognizer
Command="{Binding SelectCommand, Source={RelativeSource AncestorType={x:Type vm:DeviceScanViewModel}}}"
CommandParameter="{Binding .}" />
</Border.GestureRecognizers>
<Grid ColumnDefinitions="*,Auto">
<VerticalStackLayout Spacing="2">
<Label Text="{Binding Name}" FontAttributes="Bold" />
<Label Text="{Binding Id}" FontSize="11" TextColor="Gray" />
</VerticalStackLayout>
<Label Grid.Column="1" VerticalOptions="Center" FontSize="12" TextColor="Gray"
Text="{Binding Rssi, StringFormat='{0} dBm'}" />
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>

View File

@@ -0,0 +1,26 @@
using FieldLogger.ViewModels;
namespace FieldLogger.Views;
public partial class DeviceScanPage : ContentPage
{
private readonly DeviceScanViewModel _viewModel;
public DeviceScanPage(DeviceScanViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await _viewModel.ScanAsync();
}
protected override void OnDisappearing()
{
_viewModel.StopScan();
base.OnDisappearing();
}
}

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
xmlns:models="clr-namespace:FieldLogger.Models"
x:Class="FieldLogger.Views.HomePage"
x:DataType="vm:HomeViewModel"
Title="Field Logger">
<ScrollView>
<VerticalStackLayout Padding="16" Spacing="12">
<!-- Device connection status -->
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<Grid ColumnDefinitions="16,*,Auto" RowDefinitions="Auto,Auto" RowSpacing="10" ColumnSpacing="10">
<Ellipse Grid.Row="0" Grid.Column="0" WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
Fill="{Binding Manager.LocatorState, Converter={StaticResource StateColor}}" />
<VerticalStackLayout Grid.Row="0" Grid.Column="1" Spacing="0">
<Label Text="{Binding Manager.LocatorName, TargetNullValue='Locating Receiver — not selected'}" FontAttributes="Bold" />
<Label Text="{Binding Manager.LocatorState, Converter={StaticResource StateText}}" FontSize="12" TextColor="Gray" />
</VerticalStackLayout>
<Button Grid.Row="0" Grid.Column="2" Text="Select" FontSize="12" Padding="10,4"
Command="{Binding SelectLocatorCommand}" />
<Ellipse Grid.Row="1" Grid.Column="0" WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
Fill="{Binding Manager.GpsState, Converter={StaticResource StateColor}}" />
<VerticalStackLayout Grid.Row="1" Grid.Column="1" Spacing="0">
<Label Text="{Binding Manager.GpsName, TargetNullValue='Maglink RTK GPS — not selected'}" FontAttributes="Bold" />
<Label Text="{Binding Manager.GpsState, Converter={StaticResource StateText}}" FontSize="12" TextColor="Gray" />
</VerticalStackLayout>
<Button Grid.Row="1" Grid.Column="2" Text="Select" FontSize="12" Padding="10,4"
Command="{Binding SelectGpsCommand}" />
</Grid>
</Border>
<!-- Live GNSS fix -->
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="4">
<HorizontalStackLayout Spacing="8">
<Label Text="GPS" FontAttributes="Bold" />
<Label Text="{Binding FixStatusLabel}" TextColor="DodgerBlue" FontAttributes="Bold" />
</HorizontalStackLayout>
<Label Text="{Binding FixSummary}" FontFamily="Courier New" FontSize="13" />
<Label Text="{Binding FixAccuracy}" FontSize="12" TextColor="Gray" />
</VerticalStackLayout>
</Border>
<!-- Active job -->
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<VerticalStackLayout Spacing="0">
<Label Text="{Binding ActiveJobName}" FontAttributes="Bold" FontSize="16" />
<Label FontSize="12" TextColor="Gray">
<Label.Text>
<Binding Path="PointCount" StringFormat="{}{0} points logged" />
</Label.Text>
</Label>
</VerticalStackLayout>
<HorizontalStackLayout Grid.Column="1" Spacing="6">
<Button Text="New Job" FontSize="12" Padding="10,4" Command="{Binding NewJobCommand}" />
<Button Text="Jobs" FontSize="12" Padding="10,4" Command="{Binding SelectJobCommand}" />
</HorizontalStackLayout>
</Grid>
<Label Text="Press the log button on the receiver to capture a point."
FontSize="12" TextColor="Gray" IsVisible="{Binding HasActiveJob}" />
</VerticalStackLayout>
</Border>
<!-- Recent points -->
<Label Text="Recent Points" FontAttributes="Bold" Margin="4,8,0,0" />
<CollectionView ItemsSource="{Binding RecentPoints}" HeightRequest="320">
<CollectionView.EmptyView>
<Label Text="No points logged yet." TextColor="Gray" Margin="8" />
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:LoggedPoint">
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#FAFAFA, Dark=#242426}"
StrokeShape="RoundRectangle 8" Padding="10" Margin="0,2">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
<Label Grid.Row="0" FontSize="13" FontAttributes="Bold">
<Label.Text>
<MultiBinding StringFormat="{}{0} · {1} Hz · depth {2}">
<Binding Path="Utility" Converter="{StaticResource UtilityName}" />
<Binding Path="Frequency" />
<Binding Path="DepthRaw" />
</MultiBinding>
</Label.Text>
</Label>
<Label Grid.Row="0" Grid.Column="1" FontSize="12" TextColor="Gray"
Text="{Binding TimestampUtc, StringFormat='{0:HH:mm:ss}'}" />
<Label Grid.Row="1" Grid.ColumnSpan="2" FontSize="12" TextColor="Gray">
<Label.Text>
<MultiBinding StringFormat="{}{0:F7}, {1:F7} · {2}">
<Binding Path="Latitude" />
<Binding Path="Longitude" />
<Binding Path="FixStatus" Converter="{StaticResource FixStatusName}" />
</MultiBinding>
</Label.Text>
</Label>
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -0,0 +1,29 @@
using FieldLogger.ViewModels;
namespace FieldLogger.Views;
public partial class HomePage : ContentPage
{
private readonly HomeViewModel _viewModel;
private bool _initialized;
public HomePage(HomeViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (!_initialized)
{
_initialized = true;
await _viewModel.InitializeAsync();
}
else
{
await _viewModel.RefreshJobAsync();
}
}
}

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
xmlns:models="clr-namespace:FieldLogger.Models"
x:Class="FieldLogger.Views.JobDetailPage"
x:DataType="vm:JobDetailViewModel"
Title="{Binding JobName}">
<ContentPage.ToolbarItems>
<ToolbarItem Text="Map" Command="{Binding ViewMapCommand}" />
</ContentPage.ToolbarItems>
<Grid RowDefinitions="Auto,*" Padding="16" RowSpacing="8">
<Label Grid.Row="0" FontSize="13" TextColor="Gray"
Text="{Binding PointCount, StringFormat='{0} logged points'}" />
<CollectionView Grid.Row="1" ItemsSource="{Binding Points}">
<CollectionView.EmptyView>
<Label Text="No points logged for this job yet." TextColor="Gray" Margin="8" />
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:LoggedPoint">
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}"
StrokeShape="RoundRectangle 10" Padding="12" Margin="0,4">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="2">
<Label Grid.Row="0" FontAttributes="Bold" FontSize="14">
<Label.Text>
<MultiBinding StringFormat="{}#{0} · {1} · {2} Hz">
<Binding Path="Id" />
<Binding Path="Utility" Converter="{StaticResource UtilityName}" />
<Binding Path="Frequency" />
</MultiBinding>
</Label.Text>
</Label>
<Label Grid.Row="0" Grid.Column="1" FontSize="12" TextColor="Gray"
Text="{Binding TimestampUtc, StringFormat='{0:g}'}" />
<Label Grid.Row="1" Grid.ColumnSpan="2" FontSize="12">
<Label.Text>
<MultiBinding StringFormat="Depth {0} · Current {1} · Signal {2} · Gain {3} dB">
<Binding Path="DepthRaw" />
<Binding Path="CurrentRaw" />
<Binding Path="Signal" />
<Binding Path="GainDb" />
</MultiBinding>
</Label.Text>
</Label>
<Label Grid.Row="2" Grid.ColumnSpan="2" FontSize="12" FontFamily="Courier New">
<Label.Text>
<MultiBinding StringFormat="{}{0:F8}, {1:F8}">
<Binding Path="Latitude" />
<Binding Path="Longitude" />
</MultiBinding>
</Label.Text>
</Label>
<Label Grid.Row="3" FontSize="11" TextColor="Gray">
<Label.Text>
<MultiBinding StringFormat="{}{0} · H ±{1:F3} m · {2} sats">
<Binding Path="FixStatus" Converter="{StaticResource FixStatusName}" />
<Binding Path="Hrms" />
<Binding Path="SatellitesUsed" />
</MultiBinding>
</Label.Text>
</Label>
<Button Grid.Row="3" Grid.Column="1" Text="Delete" FontSize="11" Padding="8,2"
BackgroundColor="Transparent" TextColor="IndianRed"
Command="{Binding DeletePointCommand, Source={RelativeSource AncestorType={x:Type vm:JobDetailViewModel}}}"
CommandParameter="{Binding .}" />
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>

View File

@@ -0,0 +1,20 @@
using FieldLogger.ViewModels;
namespace FieldLogger.Views;
public partial class JobDetailPage : ContentPage
{
private readonly JobDetailViewModel _viewModel;
public JobDetailPage(JobDetailViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await _viewModel.RefreshAsync();
}
}

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
x:Class="FieldLogger.Views.JobsPage"
x:DataType="vm:JobsViewModel"
Title="Jobs">
<ContentPage.ToolbarItems>
<ToolbarItem Text="New" Command="{Binding NewJobCommand}" />
</ContentPage.ToolbarItems>
<Grid Padding="16">
<CollectionView ItemsSource="{Binding Jobs}">
<CollectionView.EmptyView>
<VerticalStackLayout Spacing="8" VerticalOptions="Center">
<Label Text="No jobs yet." TextColor="Gray" HorizontalOptions="Center" />
<Button Text="Create First Job" Command="{Binding NewJobCommand}" HorizontalOptions="Center" />
</VerticalStackLayout>
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="vm:JobListItem">
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}"
StrokeShape="RoundRectangle 10" Padding="12" Margin="0,4">
<Border.GestureRecognizers>
<TapGestureRecognizer
Command="{Binding OpenCommand, Source={RelativeSource AncestorType={x:Type vm:JobsViewModel}}}"
CommandParameter="{Binding .}" />
</Border.GestureRecognizers>
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<VerticalStackLayout Spacing="2">
<HorizontalStackLayout Spacing="8">
<Label Text="{Binding Name}" FontAttributes="Bold" />
<Border IsVisible="{Binding IsActive}" Background="DodgerBlue"
StrokeThickness="0" StrokeShape="RoundRectangle 6" Padding="6,1">
<Label Text="ACTIVE" FontSize="10" TextColor="White" />
</Border>
</HorizontalStackLayout>
<Label FontSize="12" TextColor="Gray">
<Label.Text>
<MultiBinding StringFormat="{}{0} · {1} points">
<Binding Path="CreatedLabel" />
<Binding Path="PointCount" />
</MultiBinding>
</Label.Text>
</Label>
</VerticalStackLayout>
<Button Grid.Column="1" Text="Set Active" FontSize="12" Padding="10,4" VerticalOptions="Center"
Command="{Binding SetActiveCommand, Source={RelativeSource AncestorType={x:Type vm:JobsViewModel}}}"
CommandParameter="{Binding .}" />
<Button Grid.Column="2" Text="✕" FontSize="12" Padding="10,4" VerticalOptions="Center"
BackgroundColor="IndianRed" TextColor="White"
Command="{Binding DeleteCommand, Source={RelativeSource AncestorType={x:Type vm:JobsViewModel}}}"
CommandParameter="{Binding .}" />
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>

View File

@@ -0,0 +1,20 @@
using FieldLogger.ViewModels;
namespace FieldLogger.Views;
public partial class JobsPage : ContentPage
{
private readonly JobsViewModel _viewModel;
public JobsPage(JobsViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await _viewModel.RefreshAsync();
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
x:Class="FieldLogger.Views.MapPage"
x:DataType="vm:MapViewModel"
Title="Map">
<Grid RowDefinitions="Auto,*">
<Label Grid.Row="0" Text="{Binding StatusText}" FontSize="12" TextColor="Gray" Padding="12,6" />
<!-- Populated in code-behind: native Map control on iOS/Android/macOS, Google Maps WebView on Windows. -->
<ContentView Grid.Row="1" x:Name="MapHost" />
</Grid>
</ContentPage>

View File

@@ -0,0 +1,151 @@
using FieldLogger.Models;
using FieldLogger.ViewModels;
#if WINDOWS
using System.Text.Json;
#else
using Microsoft.Maui.Controls.Maps;
using Microsoft.Maui.Maps;
using Map = Microsoft.Maui.Controls.Maps.Map;
#endif
namespace FieldLogger.Views;
public partial class MapPage : ContentPage
{
private readonly MapViewModel _viewModel;
#if WINDOWS
private WebView? _webView;
private bool _webMapReady;
private List<LoggedPoint>? _pendingPoints;
#else
private Map? _map;
#endif
public MapPage(MapViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await EnsureMapCreatedAsync();
var points = await _viewModel.LoadPointsAsync();
ShowPoints(points);
}
#if WINDOWS
private async Task EnsureMapCreatedAsync()
{
if (_webView is not null)
return;
var apiKey = _viewModel.Settings.GoogleMapsApiKey;
if (string.IsNullOrWhiteSpace(apiKey))
{
MapHost.Content = new Label
{
Text = "Google Maps API key is not set.\nEnter your Maps JavaScript API key on the Settings page.",
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
HorizontalTextAlignment = TextAlignment.Center,
};
return;
}
using var stream = await FileSystem.OpenAppPackageFileAsync("map.html");
using var reader = new StreamReader(stream);
var html = (await reader.ReadToEndAsync()).Replace("%%GOOGLE_MAPS_API_KEY%%", apiKey);
_webView = new WebView { Source = new HtmlWebViewSource { Html = html } };
_webView.Navigated += async (_, _) =>
{
_webMapReady = true;
if (_pendingPoints is not null)
{
var pts = _pendingPoints;
_pendingPoints = null;
await PushPointsToWebAsync(pts);
}
};
MapHost.Content = _webView;
}
private async void ShowPoints(List<LoggedPoint> points)
{
if (_webView is null)
return;
if (!_webMapReady)
{
_pendingPoints = points;
return;
}
await PushPointsToWebAsync(points);
}
private async Task PushPointsToWebAsync(List<LoggedPoint> points)
{
var payload = points.Select(p => new
{
id = p.Id,
lat = p.Latitude,
lng = p.Longitude,
utility = ((UmUtility)p.Utility).ToString(),
depth = p.DepthRaw,
current = p.CurrentRaw,
frequency = p.Frequency,
fix = ((GnssFixStatus)p.FixStatus).ToString(),
hrms = p.Hrms,
time = p.TimestampUtc.ToLocalTime().ToString("g"),
});
var json = JsonSerializer.Serialize(payload);
await _webView!.EvaluateJavaScriptAsync($"setPoints({json})");
}
#else
private Task EnsureMapCreatedAsync()
{
if (_map is null)
{
_map = new Map { MapType = MapType.Hybrid, IsShowingUser = false };
MapHost.Content = _map;
}
return Task.CompletedTask;
}
private void ShowPoints(List<LoggedPoint> points)
{
if (_map is null)
return;
_map.Pins.Clear();
foreach (var p in points)
{
_map.Pins.Add(new Pin
{
Label = $"#{p.Id} {(UmUtility)p.Utility} · depth {p.DepthRaw}",
Address = $"{(GnssFixStatus)p.FixStatus} · ±{p.Hrms:F3} m · {p.TimestampUtc.ToLocalTime():g}",
Location = new Location(p.Latitude, p.Longitude),
});
}
if (points.Count > 0)
{
var minLat = points.Min(p => p.Latitude);
var maxLat = points.Max(p => p.Latitude);
var minLon = points.Min(p => p.Longitude);
var maxLon = points.Max(p => p.Longitude);
var center = new Location((minLat + maxLat) / 2, (minLon + maxLon) / 2);
var spanKm = Math.Max(0.05,
Location.CalculateDistance(minLat, minLon, maxLat, maxLon, DistanceUnits.Kilometers) * 0.7);
_map.MoveToRegion(MapSpan.FromCenterAndRadius(center, Distance.FromKilometers(spanKm)));
}
}
#endif
}

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
x:Class="FieldLogger.Views.SettingsPage"
x:DataType="vm:SettingsViewModel"
Title="Settings">
<ScrollView>
<VerticalStackLayout Padding="16" Spacing="12">
<Label Text="Devices" FontAttributes="Bold" FontSize="16" />
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="10">
<Grid ColumnDefinitions="16,*,Auto,Auto" ColumnSpacing="8">
<Ellipse WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
Fill="{Binding Manager.LocatorState, Converter={StaticResource StateColor}}" />
<VerticalStackLayout Grid.Column="1" Spacing="0">
<Label Text="Locating Receiver" FontSize="12" TextColor="Gray" />
<Label Text="{Binding Manager.LocatorName, TargetNullValue='Not selected'}" FontAttributes="Bold" />
</VerticalStackLayout>
<Button Grid.Column="2" Text="Change" FontSize="12" Padding="10,4" Command="{Binding ChangeLocatorCommand}" />
<Button Grid.Column="3" Text="Forget" FontSize="12" Padding="10,4"
BackgroundColor="Transparent" TextColor="IndianRed" Command="{Binding ForgetLocatorCommand}" />
</Grid>
<BoxView HeightRequest="1" Color="{AppThemeBinding Light=#E0E0E0, Dark=#333}" />
<Grid ColumnDefinitions="16,*,Auto,Auto" ColumnSpacing="8">
<Ellipse WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
Fill="{Binding Manager.GpsState, Converter={StaticResource StateColor}}" />
<VerticalStackLayout Grid.Column="1" Spacing="0">
<Label Text="Maglink RTK GPS" FontSize="12" TextColor="Gray" />
<Label Text="{Binding Manager.GpsName, TargetNullValue='Not selected'}" FontAttributes="Bold" />
</VerticalStackLayout>
<Button Grid.Column="2" Text="Change" FontSize="12" Padding="10,4" Command="{Binding ChangeGpsCommand}" />
<Button Grid.Column="3" Text="Forget" FontSize="12" Padding="10,4"
BackgroundColor="Transparent" TextColor="IndianRed" Command="{Binding ForgetGpsCommand}" />
</Grid>
</VerticalStackLayout>
</Border>
<Label Text="Map" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="6">
<Label Text="Google Maps API Key (Windows map view)" FontSize="12" TextColor="Gray" />
<Entry Text="{Binding MapsApiKey}" Placeholder="AIza…" IsPassword="False" />
<Label FontSize="11" TextColor="Gray"
Text="On Android the key is configured in AndroidManifest.xml; iOS and macOS use Apple Maps and need no key." />
</VerticalStackLayout>
</Border>
<Label Text="Sync" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<Label Text="MQTT job sync is planned. Data is currently stored locally on this device."
FontSize="12" TextColor="Gray" />
</Border>
<Label Text="{Binding AppVersion, StringFormat='Field Logger v{0}'}"
FontSize="11" TextColor="Gray" HorizontalOptions="Center" Margin="0,20,0,0" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -0,0 +1,12 @@
using FieldLogger.ViewModels;
namespace FieldLogger.Views;
public partial class SettingsPage : ContentPage
{
public SettingsPage(SettingsViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}

122
README.md Normal file
View File

@@ -0,0 +1,122 @@
# Field Logger
Cross-platform .NET MAUI app (Windows, macOS, iOS, Android) for logging utility locate
points from an **Underground Magnetics locating receiver** paired with RTK GPS positions
from a **Maglink (H11) RTK receiver**, both over BLE.
## Solution layout
```
FieldLogger/
Models/ Device protocol models (UmLogPacket, GnssFix) and DB entities (Job, LoggedPoint)
Services/
Ble/ BleSerialClient (line-oriented GATT serial) and BleScanner (Plugin.BLE)
UmReceiverService.cs $UMPBDL protocol, PBDL schema-1 packet parsing
MaglinkService.cs $GNPOS/$GNDEV NMEA parsing + AT commands
DeviceConnectionManager.cs Auto-connect on launch, reconnect with backoff
PointLogger.cs Joins locator packets with the latest GNSS fix, saves to SQLite
Data/AppDatabase.cs sqlite-net-pcl store (jobs + points)
Sync/IMqttSyncService.cs Stub for the future MQTT job/point synchronization
ViewModels/ + Views/ Shell tabs: Home, Jobs, Map, Settings (+ DeviceScan, JobDetail)
Resources/Raw/map.html Google Maps JS page used by the Windows map view
doc/ Device protocol documentation
```
## How it works
- **First launch:** the Home page redirects to the device scan page. Pick your locating
receiver (names starting with `UMRX` or `DT100`) and, from Settings or Home, the Maglink
RTK receiver. Selections are persisted.
- **Subsequent launches:** `DeviceConnectionManager` auto-connects to both saved devices and
keeps retrying/reconnecting with backoff. Devices can be changed or forgotten in Settings.
- **Logging:** create a job (Home or Jobs tab) — it becomes the active job. When the operator
presses the log button on the UM receiver, the app receives the PBDL packet, attaches the
most recent RTK fix (if fresher than 5 s), and stores the combined record with all metadata
in SQLite (`fieldlogger.db3` in app data).
- **Map:** plots the active (or selected) job's points. Native map control on iOS/Android/macOS
(Google Maps on Android, Apple Maps on iOS/macOS); Google Maps JavaScript in a WebView on Windows.
## Setup required before running
1. **Google Maps key (Android):** replace `YOUR_GOOGLE_MAPS_ANDROID_API_KEY` in
`FieldLogger/Platforms/Android/AndroidManifest.xml` (Google Cloud Console → Maps SDK for Android).
2. **Google Maps key (Windows):** create a Maps JavaScript API key and paste it into the
Settings page of the app.
3. **Maglink GATT UUIDs:** the Maglink docs describe the serial protocol but not its GATT
table. `MaglinkService` currently assumes the Nordic UART Service
(`6e400001-b5a3-f393-e0a9-e50e24dcca9e`). Verify against the actual hardware (any BLE
scanner app will show the service UUIDs) and update the constants at the top of
`FieldLogger/Services/MaglinkService.cs` if they differ.
## Building and running
All commands are run from the repository root. (`dotnet run` does not support MAUI
projects — use `dotnet build -t:Run` to deploy and launch instead.)
### Windows
```powershell
# Build
dotnet build FieldLogger/FieldLogger.csproj -f net9.0-windows10.0.19041.0
# Run (unpackaged; WindowsPackageType=None)
dotnet build FieldLogger/FieldLogger.csproj -f net9.0-windows10.0.19041.0 -t:Run
# ...or launch the built exe directly:
.\FieldLogger\bin\Debug\net9.0-windows10.0.19041.0\win10-x64\FieldLogger.exe
```
### Android
```powershell
# Build (APK is produced under bin/Debug/net9.0-android)
dotnet build FieldLogger/FieldLogger.csproj -f net9.0-android
# Deploy + launch on the connected device or running emulator
dotnet build FieldLogger/FieldLogger.csproj -f net9.0-android -t:Run
# If several devices/emulators are attached, pick one by adb serial (see `adb devices`)
dotnet build FieldLogger/FieldLogger.csproj -f net9.0-android -t:Run -p:AdbTarget="-s emulator-5554"
```
Note: BLE does not work in the Android emulator — use a physical device
(enable USB debugging, then `adb devices` to confirm it is attached).
### iOS (requires a Mac; run these on the Mac, or from Windows via Visual Studio's paired Mac)
```bash
# Build
dotnet build FieldLogger/FieldLogger.csproj -f net9.0-ios
# Run in the default iOS Simulator (note: the simulator has no Bluetooth)
dotnet build FieldLogger/FieldLogger.csproj -f net9.0-ios -t:Run
# Run on a specific simulator by UDID (list with: xcrun simctl list devices)
dotnet build FieldLogger/FieldLogger.csproj -f net9.0-ios -t:Run -p:_DeviceName=:v2:udid=<SIMULATOR_UDID>
# Run on a physical iPhone (requires provisioning profile / signing set up in Xcode first)
dotnet build FieldLogger/FieldLogger.csproj -f net9.0-ios -t:Run -p:RuntimeIdentifier=ios-arm64 -p:_DeviceName=<DEVICE_UDID>
```
### macOS (Mac Catalyst — requires a Mac)
```bash
# Build
dotnet build FieldLogger/FieldLogger.csproj -f net9.0-maccatalyst
# Build + launch
dotnet build FieldLogger/FieldLogger.csproj -f net9.0-maccatalyst -t:Run
```
### Visual Studio
Open `FieldLogger.sln`, pick the target framework/device in the debug-target dropdown,
and press F5. This is the most convenient route for iOS from Windows (via a paired Mac)
and for Android device debugging.
## Roadmap
- **MQTT sync** (`Services/Sync/IMqttSyncService.cs` is the seam): receive configured jobs
from the server and publish logged points. `Job.RemoteId` / `Synced` and
`LoggedPoint.Synced` columns are already in the schema. Suggested client: MQTTnet.
- The Maglink can also upload positions directly via MQTT (`AT+UPLOADDATA_*` commands,
see `doc/AT_UPLOADDATA_MQTT_Configuration_EN.md`) if server-side ingestion is preferred.

181
doc/AT_Commands_Guide_EN.md Normal file
View File

@@ -0,0 +1,181 @@
# AT Commands User Guide
This document provides detailed usage instructions for the main AT commands supported by the H11 device.
## 1. AT+DEV_INIT_STA=GET
**Function**: Query device initialization status
**Command Format**:
```
AT+DEV_INIT_STA=GET
```
**Response Example**:
```
AT+DEV_INIT_STA=GET,4G,<4G_link>,<SIM_ready>,<NTRIP_flag>,<CSQ>,<data_back>,<upload_size>,<NTRIP_size>,GNSS,<status>,<reserved>,star,<SNR45>,<SNR48>,<SNR50>,<visible_stars>
OK
```
**Parameter Explanation**:
- `<4G_link>`: 4G network connection status (0=disconnected, 1=connected)
- `<SIM_ready>`: SIM card ready status (0=not ready, 1=ready)
- `<NTRIP_flag>`: NTRIP connection status (0=disconnected, 1=connected)
- `<CSQ>`: Signal quality (0-31, higher is better)
- `<data_back>`: Data upload status (0=idle, 1=uploading)
- `<upload_size>`: Upload queue size (bytes)
- `<NTRIP_size>`: NTRIP data queue size (bytes)
- `<status>`: GNSS module status
- `<SNR45>`, `<SNR48>`, `<SNR50>`: Number of satellites with SNR > 45/48/50 dB
- `<visible_stars>`: Total number of visible satellites
**Use Cases**:
- Check device module initialization status after startup
- Diagnose device connectivity and module status
- Monitor network and GNSS module status
---
## 2. AT+NEMATIME=SET
**Function**: Set NMEA output frequency
**Command Format**:
```
AT+NEMATIME=SET,<frequency>
```
**Parameters**:
- `<frequency>`: Output frequency in Hz (supported values: 1, 2, 5, 10)
**Response Example**:
```
AT+NEMATIME=SET
OK
```
**Error Response**:
```
AT+NEMATIME=SET
ERROR
```
**Use Cases**:
- Configure GNSS position data output rate
- Adjust positioning data update frequency based on application requirements
- Balance between update rate and power consumption
---
## 3. AT+NEMATIME=GET
**Function**: Query current NMEA output frequency
**Command Format**:
```
AT+NEMATIME=GET
```
**Response Example**:
```
AT+NEMATIME=GET,<frequency>
OK
```
**Parameter Explanation**:
- `<frequency>`: Current NMEA output frequency in Hz
**Use Cases**:
- Verify the current NMEA output frequency configuration
- Ensure frequency configuration has taken effect
- Troubleshoot positioning data update issues
---
## 4. AT+RTCMBASEPOS=GET
**Function**: Query base station position and distance from RTCM data source
**Command Format**:
```
AT+RTCMBASEPOS=GET
```
**Response Example** (when RTCM data is available):
```
AT+RTCMBASEPOS=GET,<latitude>,<longitude>,<altitude>,<distance>
OK
```
**Response Example** (when RTCM data is not available):
```
AT+RTCMBASEPOS=GET,0.0,0.0,0.0,0.0
OK
```
**Parameter Explanation**:
- `<latitude>`: Base station latitude in degrees (positive=North, negative=South)
- `<longitude>`: Base station longitude in degrees (positive=East, negative=West)
- `<altitude>`: Base station altitude/elevation in meters
- `<distance>`: Distance from device to base station in meters
**Use Cases**:
- Obtain current base station position information in RTK mode
- Monitor distance to reference station
- Assess differential positioning quality
- Verify RTK connection status
---
## Command Usage Examples
### Query device initialization status
```
→ AT+DEV_INIT_STA=GET
← AT+DEV_INIT_STA=GET,4G,1,1,1,25,0,0,1024,GNSS,1,0,star,8,5,2,12
← OK
```
### Set NMEA frequency to 1Hz
```
→ AT+NEMATIME=SET,1
← AT+NEMATIME=SET
← OK
```
### Query current NMEA frequency
```
→ AT+NEMATIME=GET
← AT+NEMATIME=GET,1
← OK
```
### Query base station position and distance
```
→ AT+RTCMBASEPOS=GET
← AT+RTCMBASEPOS=GET,31.135370,121.287729,22.467,1234.56
← OK
```
---
## Important Notes
1. **Command Format**: All AT commands start with "AT" and end with "\r\n"
2. **Case Insensitive**: Commands are case-insensitive
3. **Response Timeout**: Recommended timeout for each command is 5 seconds
4. **Response Format**: Successful execution returns "OK", failure returns "ERROR"
5. **RTCM Base Station Info**: Base station position is only available after receiving valid RTCM 1005/1006 messages
6. **Frequency Setting**: Device may need restart for frequency changes to take effect
7. **Line Ending**: Use CR+LF (\r\n) as line terminator
8. **Serial Parameters**: Default baud rate is 115200 bps
---
## Related Information
- **NMEA**: National Marine Electronics Association protocol, widely used in GPS/GNSS devices
- **RTCM**: Radio Technical Commission for Maritime Services, data format for differential positioning
- **RTK**: Real-Time Kinematic positioning technology
- **CSQ**: Signal Quality indicator (0-31, where 31 is excellent)
- **SNR**: Signal-to-Noise Ratio measured in dB

View File

@@ -0,0 +1,544 @@
# AT Commands Reference Guide
## Overview
This document provides detailed information about AT commands for controlling the H11hw GNSS RTK device via Bluetooth. All commands follow the format `AT+COMMAND=ACTION` where ACTION is either `SET` or `GET`.
**Response Format:**
- Success: `AT+COMMAND=ACTION\r\nOK\r\n`
- Error: `AT+COMMAND=ACTION\r\nERROR\r\n`
---
## 1. APN Configuration
Configure the APN (Access Point Name) for cellular network connection.
### AT+APN=SET
**Description:** Set APN parameters for 4G/LTE network connection.
**Format:**
```
AT+APN=SET,<flag>,<apn>,<username>,<password>\r\n
```
**Parameters:**
- `flag`: Enable/disable custom APN
- `0` = Use default APN (automatic)
- `1` = Use custom APN
- `apn`: APN name (string, max 64 chars)
- `username`: APN username (string, max 32 chars, optional)
- `password`: APN password (string, max 32 chars, optional)
**Examples:**
```
AT+APN=SET,0\r\n
AT+APN=SET,1,internet.v6.telekom,telekom,tm\r\n
AT+APN=SET,1,cmnet\r\n
```
**Response:**
```
AT+APN=SET
OK
```
### AT+APN=GET
**Description:** Query current APN configuration.
**Format:**
```
AT+APN=GET\r\n
```
**Response:**
```
AT+APN=GET,<flag>,<apn>,<username>,<password>
OK
```
**Example:**
```
AT+APN=GET,1,internet.v6.telekom,telekom,tm
OK
```
---
## 2. OLED Display Rotation
Control the OLED screen orientation.
### AT+OLEDROTATE=SET
**Description:** Set OLED display rotation angle.
**Format:**
```
AT+OLEDROTATE=SET,<angle>\r\n
```
**Parameters:**
- `angle`: Rotation angle in degrees
- `0` = Normal (0°)
- `1` = Rotated 180°
**Examples:**
```
AT+OLEDROTATE=SET,0\r\n
AT+OLEDROTATE=SET,1\r\n
```
**Response:**
```
AT+OLEDROTATE=SET
OK
```
### AT+OLEDROTATE=GET
**Description:** Query current OLED rotation setting.
**Format:**
```
AT+OLEDROTATE=GET\r\n
```
**Response:**
```
AT+OLEDROTATE=GET,<angle>
OK
```
---
## 3. Bluetooth Output Configuration
Configure which NMEA sentences and custom messages are output via Bluetooth.
### AT+BT_OUT=SET
**Description:** Configure Bluetooth data output format and content.
**Format:**
```
AT+BT_OUT=SET,<type>,<json>,<gnpos>,<gndev>,<gga>,<gst>,<rmc>,<vtg>,<gsv>,<gsa>\r\n
```
**Parameters:**
- `type`: Output mode
- `0` = Standard mode (output all raw GNSS data)
- `1` = Custom mode (selective output based on following parameters)
- `json`: Output JSON format position data (0=disable, 1=enable)
- `gnpos`: Output custom GNPOS sentence (0=disable, 1=enable)
- `gndev`: Output custom GNDEV sentence (0=disable, 1=enable)
- `gga`: Output NMEA GGA sentence (0=disable, 1=enable)
- `gst`: Output NMEA GST sentence (0=disable, 1=enable)
- `rmc`: Output NMEA RMC sentence (0=disable, 1=enable)
- `vtg`: Output NMEA VTG sentence (0=disable, 1=enable)
- `gsv`: Output NMEA GSV sentence (0=disable, 1=enable)
- `gsa`: Output NMEA GSA sentence (0=disable, 1=enable)
**Examples:**
```
AT+BT_OUT=SET,0\r\n
AT+BT_OUT=SET,1,1,1,1,1,1,1,1,1,1\r\n
AT+BT_OUT=SET,1,0,1,1,1,0,0,0,0,0\r\n
AT+BT_OUT=SET,1,,,,1,1,1,1\r\n
```
**Notes:**
- Empty parameters retain previous values
- When `type=0`, all other parameters are ignored
- When `type=1`, you can selectively enable/disable each output
**Response:**
```
AT+BT_OUT=SET
OK
```
### AT+BT_OUT=GET
**Description:** Query current Bluetooth output configuration.
**Format:**
```
AT+BT_OUT=GET\r\n
```
**Response:**
```
AT+BT_OUT=GET,<type>,<json>,<gnpos>,<gndev>,<gga>,<gst>,<rmc>,<vtg>,<gsv>,<gsa>
OK
```
---
## 4. Data Upload Configuration
Configure network data upload parameters (TCP/HTTP/MQTT).
### AT+UPLOADDATA_PARM=SET
**Description:** Set data upload server parameters.
**Format:**
```
AT+UPLOADDATA_PARM=SET,<enable>,<server>,<port>\r\n
```
**Parameters:**
- `enable`: Enable/disable data upload
- `0` = Disable
- `1` = Enable
- `server`: Server address (IP or domain name, max 128 chars)
- `port`: Server port number (1-65535)
**Examples:**
```
AT+UPLOADDATA_PARM=SET,0\r\n
AT+UPLOADDATA_PARM=SET,1,192.168.0.1,2202\r\n
AT+UPLOADDATA_PARM=SET,1,data.example.com,8080\r\n
```
**Response:**
```
AT+UPLOADDATA_PARM=SET
OK
```
### AT+UPLOADDATA_PARM=GET
**Description:** Query data upload server configuration.
**Format:**
```
AT+UPLOADDATA_PARM=GET\r\n
```
**Response:**
```
AT+UPLOADDATA_PARM=GET,<enable>,<server>,<port>
OK
```
---
## 5. Data Upload Type Configuration
Configure upload protocol and authentication.
### AT+UPLOADDATA_TYPE=SET
**Description:** Set upload protocol type and credentials.
**Format:**
```
AT+UPLOADDATA_TYPE=SET,<type>,USERNAME,<username>,PASSWORD,<password>\r\n
```
**Parameters:**
- `type`: Upload protocol type
- `0` = TCP
- `1` = HTTP
- `2` = MQTT
- `username`: Authentication username (required for MQTT)
- `password`: Authentication password (required for MQTT)
**Examples:**
```
AT+UPLOADDATA_TYPE=SET,0\r\n
AT+UPLOADDATA_TYPE=SET,1\r\n
AT+UPLOADDATA_TYPE=SET,2,USERNAME,user11,PASSWORD,password11\r\n
```
**Response:**
```
AT+UPLOADDATA_TYPE=SET
OK
```
### AT+UPLOADDATA_TYPE=GET
**Description:** Query upload protocol configuration.
**Format:**
```
AT+UPLOADDATA_TYPE=GET\r\n
```
**Response:**
```
AT+UPLOADDATA_TYPE=GET,<type>,<username>,<password>
OK
```
---
## 6. Rover Mode Configuration
Configure NTRIP client parameters for RTK rover mode.
### AT+ROVER_PARM=SET
**Description:** Set NTRIP/CORS server parameters for receiving RTK corrections.
**Format:**
```
AT+ROVER_PARM=SET,<enable>,<server>,<port>,<mountpoint>,<username>,<password>\r\n
```
**Parameters:**
- `enable`: Enable/disable NTRIP client
- `0` = Disable
- `1` = Enable
- `server`: NTRIP server address (IP or domain name)
- `port`: NTRIP server port (typically 2101 or 2102)
- `mountpoint`: NTRIP mountpoint name
- `username`: NTRIP authentication username
- `password`: NTRIP authentication password
**Examples:**
```
AT+ROVER_PARM=SET,0\r\n
AT+ROVER_PARM=SET,1,211.144.118.5,2102,RTCM32,username,password\r\n
AT+ROVER_PARM=SET,1,sh.mijiatech.cn,2102,22C018,zd,zd\r\n
```
**Response:**
```
AT+ROVER_PARM=SET
OK
```
### AT+ROVER_PARM=GET
**Description:** Query NTRIP client configuration.
**Format:**
```
AT+ROVER_PARM=GET\r\n
```
**Response:**
```
AT+ROVER_PARM=GET,<enable>,<server>,<port>,<mountpoint>,<username>,<password>
OK
```
---
## 7. Base Station Mode Configuration
Configure base station parameters for outputting RTK corrections.
### AT+BASE_PARM=SET
**Description:** Set base station mode parameters.
**Format (Mode 0 - Disable):**
```
AT+BASE_PARM=SET,0\r\n
```
**Format (Mode 1 - TCP Server):**
```
AT+BASE_PARM=SET,1,<server>,<port>\r\n
```
**Format (Mode 2 - NTRIP Caster):**
```
AT+BASE_PARM=SET,2,<server>,<port>,<mountpoint>,<username>,<password>\r\n
```
**Parameters:**
- Mode `0`: Disable base station mode
- Mode `1`: TCP server mode
- `server`: Server address to send RTCM data
- `port`: Server port
- Mode `2`: NTRIP caster mode
- `server`: NTRIP caster address
- `port`: NTRIP caster port
- `mountpoint`: Mountpoint name
- `username`: Authentication username
- `password`: Authentication password
**Examples:**
```
AT+BASE_PARM=SET,0\r\n
AT+BASE_PARM=SET,1,192.168.1.100,2102\r\n
AT+BASE_PARM=SET,2,sh.mijiatech.cn,2102,RTCM32-1,zd,zd\r\n
```
**Response:**
```
AT+BASE_PARM=SET
OK
```
### AT+BASE_PARM=GET
**Description:** Query base station configuration.
**Format:**
```
AT+BASE_PARM=GET\r\n
```
**Response (Mode 0):**
```
AT+BASE_PARM=GET,0
OK
```
**Response (Mode 1):**
```
AT+BASE_PARM=GET,1,<server>,<port>
OK
```
**Response (Mode 2):**
```
AT+BASE_PARM=GET,2,<server>,<port>,<mountpoint>,<username>,<password>
OK
```
---
## 8. GNSS Mode Configuration
Configure device operating mode (Rover/Base/Static).
### AT+GNSS_MODE=SET
**Description:** Set GNSS operating mode.
**Format:**
```
AT+GNSS_MODE=SET,<mode>\r\n
```
**Parameters:**
- `mode`: Operating mode
- `0` = Rover mode (mobile RTK positioning)
- `1` = Base station mode (output RTK corrections)
- `2` = Static mode (stationary positioning)
**Examples:**
```
AT+GNSS_MODE=SET,0\r\n
AT+GNSS_MODE=SET,1\r\n
AT+GNSS_MODE=SET,2\r\n
```
**Response:**
```
AT+GNSS_MODE=SET
OK
```
**Notes:**
- Changing mode may require device restart
- Base mode requires fixed position configuration
- Rover mode requires NTRIP configuration for RTK
### AT+GNSS_MODE=GET
**Description:** Query current GNSS operating mode.
**Format:**
```
AT+GNSS_MODE=GET\r\n
```
**Response:**
```
AT+GNSS_MODE=GET,<mode>
OK
```
**Example:**
```
AT+GNSS_MODE=GET,0
OK
```
---
## Command Summary Table
| Command | SET | GET | Description |
|---------|-----|-----|-------------|
| AT+APN | ✓ | ✓ | Configure cellular APN |
| AT+OLEDROTATE | ✓ | ✓ | Set display rotation |
| AT+BT_OUT | ✓ | ✓ | Configure Bluetooth output |
| AT+UPLOADDATA_PARM | ✓ | ✓ | Set upload server |
| AT+UPLOADDATA_TYPE | ✓ | ✓ | Set upload protocol |
| AT+ROVER_PARM | ✓ | ✓ | Configure NTRIP client |
| AT+BASE_PARM | ✓ | ✓ | Configure base station |
| AT+GNSS_MODE | ✓ | ✓ | Set operating mode |
---
## Common Usage Scenarios
### Scenario 1: Configure Rover Mode for RTK Positioning
```
AT+GNSS_MODE=SET,0\r\n
AT+ROVER_PARM=SET,1,rtk.server.com,2101,MOUNT01,user,pass\r\n
AT+BT_OUT=SET,1,0,1,1,1,1,0,0,0,0\r\n
```
### Scenario 2: Configure Base Station Mode
```
AT+GNSS_MODE=SET,1\r\n
AT+BASE_PARM=SET,2,caster.server.com,2101,BASE01,user,pass\r\n
```
### Scenario 3: Enable All NMEA Output via Bluetooth
```
AT+BT_OUT=SET,1,0,1,1,1,1,1,1,1,1\r\n
```
### Scenario 4: Configure Custom APN
```
AT+APN=SET,1,internet,username,password\r\n
```
---
## Error Handling
**Common Error Responses:**
- `ERROR` - Invalid command format or parameters
- No response - Command timeout (check Bluetooth connection)
**Troubleshooting:**
1. Ensure commands end with `\r\n`
2. Check parameter count and format
3. Verify Bluetooth connection is active
4. Wait for response before sending next command
---
## Notes
- All commands are case-sensitive
- Commands must end with `\r\n` (carriage return + line feed)
- String parameters should not contain commas
- Empty parameters in SET commands retain previous values
- Configuration changes are saved to flash memory automatically
- Some changes may require device restart to take effect
---
**Document Version:** 1.0
**Firmware Version:** 1.2.37
**Last Updated:** 2026-05-07

View File

@@ -0,0 +1,122 @@
# MQTT Data Upload Configuration via AT Commands
## Overview
Configure device data upload functionality using `AT+UPLOADDATA_PARM` and `AT+UPLOADDATA_TYPE` commands. Configuration is saved to Flash and persists across power cycles.
---
## Configuration Steps
### Step 1: Set Server Address and Upload Frequency
**Command:** `AT+UPLOADDATA_PARM=SET`
**Format:**
```
AT+UPLOADDATA_PARM=SET,<freq>,<ip>,<port>\r\n
```
**Parameters:**
| Parameter | Description | Values |
|-----------|-------------|--------|
| freq | Upload frequency | 0=OFF, 1=1s, 2=2s, 5=5s, 10=10s, 255=Follow GGA rate |
| ip | Server address | IP or domain name, max 99 bytes |
| port | Server port | 1~65535 |
**Example:**
```
AT+UPLOADDATA_PARM=SET,1,mqtt.example.com,1883
```
**Response:**
```
OK
```
---
### Step 2: Set Protocol Type to MQTT and Configure Authentication
**Command:** `AT+UPLOADDATA_TYPE=SET`
**Format:**
```
AT+UPLOADDATA_TYPE=SET,2,USERNAME,<user>,PASSWORD,<password>,CLIENTID,<clientid>,TOPIC,<topic>\r\n
```
**Parameters:**
| Parameter | Description | Max Length |
|-----------|-------------|------------|
| type | Protocol type, use `2` for MQTT | — |
| USERNAME | MQTT username | 63 bytes |
| PASSWORD | MQTT password | 63 bytes |
| CLIENTID | MQTT client ID | 63 bytes |
| TOPIC | Publish topic | 63 bytes |
**Example:**
```
AT+UPLOADDATA_TYPE=SET,2,USERNAME,myuser,PASSWORD,mypass,CLIENTID,device001,TOPIC,/gnss/data
```
**Response:**
```
OK
```
---
## Query Current Configuration
### Query Server Parameters
```
AT+UPLOADDATA_PARM=GET\r\n
```
**Response Format:**
```
AT+UPLOADDATA_PARM=GET,<freq>,<ip>,<port>
OK
```
### Query Protocol Type and MQTT Parameters
```
AT+UPLOADDATA_TYPE=GET\r\n
```
**Response Format (MQTT):**
```
AT+UPLOADDATA_TYPE=GET,2,USERNAME,<user>,PASSWORD,<pass>,CLIENTID,<id>,TOPIC,<topic>,SUBTOPIC,<subtopic>
OK
```
---
## Protocol Type Reference
| type | Protocol | Description |
|------|----------|-------------|
| 0 | TCP | Raw TCP connection, no additional parameters |
| 1 | HTTP | HTTP POST upload, requires USERNAME/PASSWORD |
| 2 | MQTT | MQTT publish, requires USERNAME/PASSWORD/CLIENTID/TOPIC |
| 3 | JT808 | JT808 protocol |
---
## Complete Configuration Example
```
# 1. Set server address with 1-second upload interval
AT+UPLOADDATA_PARM=SET,1,mqtt.example.com,1883
# 2. Set MQTT protocol and authentication
AT+UPLOADDATA_TYPE=SET,2,USERNAME,myuser,PASSWORD,mypass,CLIENTID,device001,TOPIC,/gnss/data
# 3. Verify configuration
AT+UPLOADDATA_PARM=GET
AT+UPLOADDATA_TYPE=GET
```

View File

@@ -0,0 +1,542 @@
# Custom NMEA Sentences Reference
## Overview
This document describes the custom NMEA-format sentences (GNPOS and GNDEV) output by the H11hw GNSS RTK device. These proprietary sentences provide comprehensive positioning data and device information in a standardized NMEA format.
**NMEA Format Structure:**
```
$<sentence>*<checksum>\r\n
```
- `$` - Start delimiter
- `<sentence>` - Sentence identifier and data fields (comma-separated)
- `*` - Checksum delimiter
- `<checksum>` - Two-digit hexadecimal XOR checksum
- `\r\n` - End delimiter (CR+LF)
---
## 1. GNPOS - Position Data Sentence
### Description
The GNPOS sentence provides comprehensive real-time positioning information including coordinates, accuracy metrics, satellite status, and system information.
### Output Frequency
- **Default:** Every 800ms (1.25 Hz)
- **Configurable:** Via `AT+BT_OUT=SET` command
### Format
```
$GNPOS,<lat>,<lon>,<alt>,<altCorr>,<status>,<hdop>,<hrms>,<vrms>,<satUsed>,<satView>,<speed>,<heading>,<battV>,<battPct>,<ntripFlag>,<rtcmSize>,<age>,<timestamp>,<tiltAngle>*<checksum>\r\n
```
### Field Definitions
| Field | Index | Type | Unit | Description | Example |
|-------|-------|------|------|-------------|---------|
| Sentence ID | 0 | String | - | Fixed identifier | GNPOS |
| lat | 1 | Float | degrees | Latitude (signed, 9 decimals) | 31.140518542 |
| lon | 2 | Float | degrees | Longitude (signed, 9 decimals) | 121.284018564 |
| alt | 3 | Float | meters | Altitude above sea level (3 decimals) | 45.123 |
| altCorr | 4 | Float | meters | Corrected altitude (3 decimals) | 45.456 |
| status | 5 | Integer | - | Positioning status (see table below) | 4 |
| hdop | 6 | Float | - | Horizontal Dilution of Precision (2 decimals) | 0.85 |
| hrms | 7 | Float | meters | Horizontal RMS error (3 decimals) | 0.012 |
| vrms | 8 | Float | meters | Vertical RMS error (3 decimals) | 0.018 |
| satUsed | 9 | Integer | - | Number of satellites used in solution | 18 |
| satView | 10 | Integer | - | Number of satellites visible | 24 |
| speed | 11 | Float | km/h | Ground speed (3 decimals) | 12.345 |
| heading | 12 | Float | degrees | Heading/course over ground (2 decimals) | 135.67 |
| battV | 13 | Float | volts | Battery voltage (2 decimals) | 4.15 |
| battPct | 14 | Integer | % | Battery percentage (0-100) | 85 |
| ntripFlag | 15 | Integer | - | NTRIP connection status (0=disconnected, 1=connected) | 1 |
| rtcmSize | 16 | Integer | bytes | RTCM data size received | 1024 |
| age | 17 | Float | seconds | Differential correction age (1 decimal) | 1.2 |
| timestamp | 18 | Integer | seconds | System timestamp (Unix time) | 1714953600 |
| tiltAngle | 19 | Float | degrees | Device tilt angle (1 decimal) | 2.5 |
### Positioning Status Values
| Value | Status | Description |
|-------|--------|-------------|
| 0 | No Fix | No valid position |
| 1 | Single Point | Autonomous GPS/GNSS positioning |
| 2 | DGPS | Differential GPS (SBAS corrected) |
| 4 | RTK Fixed | RTK fixed solution (cm-level accuracy) |
| 5 | RTK Float | RTK float solution (dm-level accuracy) |
### Example Output
```
$GNPOS,31.140518542,121.284018564,45.123,45.456,4,0.85,0.012,0.018,18,24,12.345,135.67,4.15,85,1,1024,1.2,1714953600,2.5*5A\r\n
```
### Parsing Example (Python)
```python
def parse_gnpos(sentence):
# Remove $, checksum, and whitespace
data = sentence.strip().split('*')[0].lstrip('$')
fields = data.split(',')
if fields[0] != 'GNPOS' or len(fields) != 20:
return None
return {
'latitude': float(fields[1]),
'longitude': float(fields[2]),
'altitude': float(fields[3]),
'altitude_corrected': float(fields[4]),
'status': int(fields[5]),
'hdop': float(fields[6]),
'hrms': float(fields[7]),
'vrms': float(fields[8]),
'satellites_used': int(fields[9]),
'satellites_visible': int(fields[10]),
'speed_kmh': float(fields[11]),
'heading': float(fields[12]),
'battery_voltage': float(fields[13]),
'battery_percent': int(fields[14]),
'ntrip_connected': bool(int(fields[15])),
'rtcm_size': int(fields[16]),
'correction_age': float(fields[17]),
'timestamp': int(fields[18]),
'tilt_angle': float(fields[19])
}
```
### Parsing Example (C)
```c
typedef struct {
double latitude;
double longitude;
float altitude;
float altitude_corrected;
int status;
float hdop;
float hrms;
float vrms;
int satellites_used;
int satellites_visible;
float speed_kmh;
float heading;
float battery_voltage;
int battery_percent;
int ntrip_connected;
int rtcm_size;
float correction_age;
long long timestamp;
float tilt_angle;
} gnpos_data_t;
int parse_gnpos(const char* sentence, gnpos_data_t* data) {
if (strncmp(sentence, "$GNPOS,", 7) != 0) {
return -1;
}
int result = sscanf(sentence,
"$GNPOS,%lf,%lf,%f,%f,%d,%f,%f,%f,%d,%d,%f,%f,%f,%d,%d,%d,%f,%lld,%f*",
&data->latitude,
&data->longitude,
&data->altitude,
&data->altitude_corrected,
&data->status,
&data->hdop,
&data->hrms,
&data->vrms,
&data->satellites_used,
&data->satellites_visible,
&data->speed_kmh,
&data->heading,
&data->battery_voltage,
&data->battery_percent,
&data->ntrip_connected,
&data->rtcm_size,
&data->correction_age,
&data->timestamp,
&data->tilt_angle
);
return (result == 19) ? 0 : -1;
}
```
---
## 2. GNDEV - Device Information Sentence
### Description
The GNDEV sentence provides device identification and cellular module information. This sentence is useful for device management, inventory tracking, and remote diagnostics.
### Output Frequency
- **Bluetooth disconnected:** Every 5 seconds
- **Bluetooth connected (first 10 seconds):** Every 800ms (fast sync)
- **Bluetooth connected (after 10 seconds):** Every 5 seconds
### Format
```
$GNDEV,<sn>,<pcb_version>,<fw_version>,<imei>,<imsi>,<iccid>*<checksum>\r\n
```
### Field Definitions
| Field | Index | Type | Length | Description | Example |
|-------|-------|------|--------|-------------|---------|
| Sentence ID | 0 | String | - | Fixed identifier | GNDEV |
| sn | 1 | String | Variable | Device serial number (unique ID) | H11-20240507-001 |
| pcb_version | 2 | String | Variable | PCB hardware version | V1.2 |
| fw_version | 3 | String | Variable | Firmware version (x.x.x format) | 1.2.37 |
| imei | 4 | String | 15 | 4G module IMEI (International Mobile Equipment Identity) | 866123456789012 |
| imsi | 5 | String | 15 | SIM card IMSI (International Mobile Subscriber Identity) | 460012345678901 |
| iccid | 6 | String | 20 | SIM card ICCID (Integrated Circuit Card ID) | 89860123456789012345 |
### Example Output
```
$GNDEV,H11-20240507-001,V1.2,1.2.37,866123456789012,460012345678901,89860123456789012345*3F\r\n
```
### Parsing Example (Python)
```python
def parse_gndev(sentence):
# Remove $, checksum, and whitespace
data = sentence.strip().split('*')[0].lstrip('$')
fields = data.split(',')
if fields[0] != 'GNDEV' or len(fields) != 7:
return None
return {
'serial_number': fields[1],
'pcb_version': fields[2],
'firmware_version': fields[3],
'imei': fields[4],
'imsi': fields[5],
'iccid': fields[6]
}
```
### Parsing Example (C)
```c
typedef struct {
char serial_number[32];
char pcb_version[16];
char firmware_version[16];
char imei[16];
char imsi[16];
char iccid[32];
} gndev_data_t;
int parse_gndev(const char* sentence, gndev_data_t* data) {
if (strncmp(sentence, "$GNDEV,", 7) != 0) {
return -1;
}
int result = sscanf(sentence,
"$GNDEV,%31[^,],%15[^,],%15[^,],%15[^,],%15[^,],%31[^*]*",
data->serial_number,
data->pcb_version,
data->firmware_version,
data->imei,
data->imsi,
data->iccid
);
return (result == 6) ? 0 : -1;
}
```
---
## 3. Checksum Calculation
### Algorithm
The checksum is calculated as the XOR of all characters between `$` and `*` (exclusive).
### Example (C)
```c
uint8_t calculate_nmea_checksum(const char* sentence) {
uint8_t checksum = 0;
const char* ptr = sentence;
// Skip '$' if present
if (*ptr == '$') ptr++;
// XOR all characters until '*' or end
while (*ptr && *ptr != '*') {
checksum ^= *ptr;
ptr++;
}
return checksum;
}
// Verify checksum
int verify_nmea_checksum(const char* sentence) {
const char* asterisk = strchr(sentence, '*');
if (!asterisk) return 0;
uint8_t calculated = calculate_nmea_checksum(sentence);
uint8_t received = 0;
sscanf(asterisk + 1, "%02X", &received);
return (calculated == received);
}
```
### Example (Python)
```python
def calculate_nmea_checksum(sentence):
# Remove $ and everything after *
data = sentence.lstrip('$').split('*')[0]
checksum = 0
for char in data:
checksum ^= ord(char)
return checksum
def verify_nmea_checksum(sentence):
if '*' not in sentence:
return False
data, checksum_str = sentence.split('*')
calculated = calculate_nmea_checksum(data)
received = int(checksum_str[:2], 16)
return calculated == received
```
---
## 4. Integration Guide
### Enabling Custom Sentences
Use the `AT+BT_OUT=SET` command to enable GNPOS and GNDEV output:
```
AT+BT_OUT=SET,1,0,1,1,0,0,0,0,0,0\r\n
```
This enables:
- Custom mode (`type=1`)
- GNPOS sentence (`gnpos=1`)
- GNDEV sentence (`gndev=1`)
- Disables JSON and standard NMEA sentences
### Complete Data Stream Example
```
$GNDEV,H11-20240507-001,V1.2,1.2.37,866123456789012,460012345678901,89860123456789012345*3F\r\n
$GNPOS,31.140518542,121.284018564,45.123,45.456,4,0.85,0.012,0.018,18,24,12.345,135.67,4.15,85,1,1024,1.2,1714953600,2.5*5A\r\n
$GNPOS,31.140518543,121.284018565,45.124,45.457,4,0.85,0.012,0.018,18,24,12.346,135.68,4.15,85,1,1024,1.2,1714953601,2.5*5B\r\n
$GNPOS,31.140518544,121.284018566,45.125,45.458,4,0.85,0.012,0.018,18,24,12.347,135.69,4.15,85,1,1024,1.2,1714953602,2.5*5C\r\n
$GNDEV,H11-20240507-001,V1.2,1.2.37,866123456789012,460012345678901,89860123456789012345*3F\r\n
```
### Bluetooth Receiver Implementation
```python
import serial
import time
class H11RTKReceiver:
def __init__(self, port, baudrate=115200):
self.serial = serial.Serial(port, baudrate, timeout=1)
self.gnpos_callback = None
self.gndev_callback = None
def set_gnpos_callback(self, callback):
self.gnpos_callback = callback
def set_gndev_callback(self, callback):
self.gndev_callback = callback
def read_loop(self):
buffer = ""
while True:
data = self.serial.read(256).decode('utf-8', errors='ignore')
buffer += data
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if line.startswith('$GNPOS,'):
if verify_nmea_checksum(line):
gnpos_data = parse_gnpos(line)
if self.gnpos_callback and gnpos_data:
self.gnpos_callback(gnpos_data)
elif line.startswith('$GNDEV,'):
if verify_nmea_checksum(line):
gndev_data = parse_gndev(line)
if self.gndev_callback and gndev_data:
self.gndev_callback(gndev_data)
# Usage example
def on_position_update(data):
print(f"Position: {data['latitude']:.9f}, {data['longitude']:.9f}")
print(f"Status: {data['status']}, Satellites: {data['satellites_used']}")
print(f"Accuracy: H={data['hrms']:.3f}m, V={data['vrms']:.3f}m")
def on_device_info(data):
print(f"Device: {data['serial_number']}")
print(f"Firmware: {data['firmware_version']}")
print(f"IMEI: {data['imei']}")
receiver = H11RTKReceiver('/dev/rfcomm0')
receiver.set_gnpos_callback(on_position_update)
receiver.set_gndev_callback(on_device_info)
receiver.read_loop()
```
---
## 5. Data Quality Indicators
### RTK Solution Quality
| Status | HRMS Range | VRMS Range | Typical Accuracy |
|--------|------------|------------|------------------|
| RTK Fixed (4) | < 0.02m | < 0.03m | 1-2 cm horizontal, 2-3 cm vertical |
| RTK Float (5) | 0.1-0.5m | 0.2-1.0m | 10-50 cm horizontal, 20-100 cm vertical |
| DGPS (2) | 0.5-2.0m | 1.0-3.0m | 0.5-2 m horizontal, 1-3 m vertical |
| Single (1) | 2.0-10.0m | 3.0-15.0m | 2-10 m horizontal, 3-15 m vertical |
### HDOP Quality Assessment
| HDOP Value | Quality | Description |
|------------|---------|-------------|
| < 1.0 | Excellent | Ideal satellite geometry |
| 1.0 - 2.0 | Good | Acceptable for RTK |
| 2.0 - 5.0 | Moderate | Usable but degraded |
| > 5.0 | Poor | Unreliable positioning |
### Correction Age Guidelines
| Age (seconds) | RTK Quality | Recommendation |
|---------------|-------------|----------------|
| < 3.0 | Excellent | Optimal RTK performance |
| 3.0 - 10.0 | Good | Acceptable for most applications |
| 10.0 - 30.0 | Degraded | Consider reconnecting |
| > 30.0 | Poor | RTK solution unreliable |
---
## 6. Troubleshooting
### No GNPOS/GNDEV Output
**Possible Causes:**
1. Bluetooth output not configured
2. Custom mode disabled
3. Bluetooth connection lost
**Solutions:**
```
AT+BT_OUT=GET\r\n # Check current configuration
AT+BT_OUT=SET,1,0,1,1,0,0,0,0,0,0\r\n # Enable custom sentences
```
### Invalid Checksum
**Possible Causes:**
1. Data corruption during transmission
2. Bluetooth interference
3. Buffer overflow
**Solutions:**
- Verify checksum calculation algorithm
- Check Bluetooth signal strength
- Increase receive buffer size
### Incorrect Data Values
**Possible Causes:**
1. GNSS not initialized
2. No satellite fix
3. Antenna disconnected
**Solutions:**
- Check `status` field (should be > 0)
- Verify `satView` and `satUsed` (should be > 4)
- Check antenna connection
---
## 7. Best Practices
### Data Processing
1. **Always verify checksums** before parsing data
2. **Check positioning status** before using coordinates
3. **Monitor correction age** for RTK applications
4. **Validate satellite count** (minimum 4 for 3D fix)
5. **Check HDOP values** for solution quality
### Performance Optimization
1. **Buffer management:** Use circular buffers for continuous data streams
2. **Parsing efficiency:** Pre-compile regex patterns or use fixed-format parsing
3. **Callback design:** Keep callbacks lightweight, defer heavy processing
4. **Error handling:** Implement timeout and retry mechanisms
### Application Design
1. **Position filtering:** Apply Kalman filtering for smooth trajectories
2. **Status monitoring:** Track RTK solution quality over time
3. **Battery management:** Monitor voltage and percentage for low-power warnings
4. **Connection health:** Track NTRIP status and correction age
---
## Appendix A: Complete Message Examples
### RTK Fixed Solution (High Quality)
```
$GNPOS,31.140518542,121.284018564,45.123,45.456,4,0.85,0.012,0.018,18,24,0.000,0.00,4.15,85,1,1024,1.2,1714953600,0.5*XX\r\n
```
- Status: 4 (RTK Fixed)
- HRMS: 0.012m (1.2cm horizontal accuracy)
- VRMS: 0.018m (1.8cm vertical accuracy)
- 18 satellites used, 24 visible
- NTRIP connected, 1.2s correction age
### RTK Float Solution (Medium Quality)
```
$GNPOS,31.140518542,121.284018564,45.123,45.456,5,1.20,0.250,0.450,15,22,5.234,45.30,4.10,80,1,1024,3.5,1714953600,1.2*XX\r\n
```
- Status: 5 (RTK Float)
- HRMS: 0.250m (25cm horizontal accuracy)
- VRMS: 0.450m (45cm vertical accuracy)
- 15 satellites used, 22 visible
- NTRIP connected, 3.5s correction age
### Single Point Solution (Low Quality)
```
$GNPOS,31.140518542,121.284018564,45.123,45.456,1,2.50,3.500,5.200,8,15,12.345,135.67,3.95,65,0,0,0.0,1714953600,2.5*XX\r\n
```
- Status: 1 (Single Point)
- HRMS: 3.500m (3.5m horizontal accuracy)
- VRMS: 5.200m (5.2m vertical accuracy)
- 8 satellites used, 15 visible
- NTRIP disconnected
---
**Document Version:** 1.0
**Firmware Version:** 1.2.37
**Last Updated:** 2026-05-07