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:
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git init)",
|
||||
"Bash(git add .)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
484
.gitignore
vendored
Normal file
484
.gitignore
vendored
Normal 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
34
FieldLogger.sln
Normal 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
21
FieldLogger/App.xaml
Normal 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
14
FieldLogger/App.xaml.cs
Normal 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
16
FieldLogger/AppShell.xaml
Normal 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>
|
||||
14
FieldLogger/AppShell.xaml.cs
Normal file
14
FieldLogger/AppShell.xaml.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
68
FieldLogger/Converters/AppConverters.cs
Normal file
68
FieldLogger/Converters/AppConverters.cs
Normal 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();
|
||||
}
|
||||
82
FieldLogger/FieldLogger.csproj
Normal file
82
FieldLogger/FieldLogger.csproj
Normal 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>
|
||||
2
FieldLogger/GlobalXmlns.cs
Normal file
2
FieldLogger/GlobalXmlns.cs
Normal 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")]
|
||||
64
FieldLogger/MauiProgram.cs
Normal file
64
FieldLogger/MauiProgram.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
11
FieldLogger/Models/DeviceKind.cs
Normal file
11
FieldLogger/Models/DeviceKind.cs
Normal 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,
|
||||
}
|
||||
155
FieldLogger/Models/GnssFix.cs
Normal file
155
FieldLogger/Models/GnssFix.cs
Normal 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
23
FieldLogger/Models/Job.cs
Normal 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; }
|
||||
}
|
||||
145
FieldLogger/Models/LoggedPoint.cs
Normal file
145
FieldLogger/Models/LoggedPoint.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
51
FieldLogger/Models/UmDeviceInfo.cs
Normal file
51
FieldLogger/Models/UmDeviceInfo.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
141
FieldLogger/Models/UmLogPacket.cs
Normal file
141
FieldLogger/Models/UmLogPacket.cs
Normal 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);
|
||||
}
|
||||
18
FieldLogger/Platforms/Android/AndroidManifest.xml
Normal file
18
FieldLogger/Platforms/Android/AndroidManifest.xml
Normal 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>
|
||||
10
FieldLogger/Platforms/Android/MainActivity.cs
Normal file
10
FieldLogger/Platforms/Android/MainActivity.cs
Normal 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
|
||||
{
|
||||
}
|
||||
15
FieldLogger/Platforms/Android/MainApplication.cs
Normal file
15
FieldLogger/Platforms/Android/MainApplication.cs
Normal 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();
|
||||
}
|
||||
@@ -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>
|
||||
9
FieldLogger/Platforms/MacCatalyst/AppDelegate.cs
Normal file
9
FieldLogger/Platforms/MacCatalyst/AppDelegate.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Foundation;
|
||||
|
||||
namespace FieldLogger;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
17
FieldLogger/Platforms/MacCatalyst/Entitlements.plist
Normal file
17
FieldLogger/Platforms/MacCatalyst/Entitlements.plist
Normal 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>
|
||||
|
||||
42
FieldLogger/Platforms/MacCatalyst/Info.plist
Normal file
42
FieldLogger/Platforms/MacCatalyst/Info.plist
Normal 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>
|
||||
15
FieldLogger/Platforms/MacCatalyst/Program.cs
Normal file
15
FieldLogger/Platforms/MacCatalyst/Program.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
16
FieldLogger/Platforms/Tizen/Main.cs
Normal file
16
FieldLogger/Platforms/Tizen/Main.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
15
FieldLogger/Platforms/Tizen/tizen-manifest.xml
Normal file
15
FieldLogger/Platforms/Tizen/tizen-manifest.xml
Normal 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>
|
||||
8
FieldLogger/Platforms/Windows/App.xaml
Normal file
8
FieldLogger/Platforms/Windows/App.xaml
Normal 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>
|
||||
24
FieldLogger/Platforms/Windows/App.xaml.cs
Normal file
24
FieldLogger/Platforms/Windows/App.xaml.cs
Normal 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();
|
||||
}
|
||||
|
||||
47
FieldLogger/Platforms/Windows/Package.appxmanifest
Normal file
47
FieldLogger/Platforms/Windows/Package.appxmanifest
Normal 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>
|
||||
15
FieldLogger/Platforms/Windows/app.manifest
Normal file
15
FieldLogger/Platforms/Windows/app.manifest
Normal 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>
|
||||
9
FieldLogger/Platforms/iOS/AppDelegate.cs
Normal file
9
FieldLogger/Platforms/iOS/AppDelegate.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Foundation;
|
||||
|
||||
namespace FieldLogger;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
36
FieldLogger/Platforms/iOS/Info.plist
Normal file
36
FieldLogger/Platforms/iOS/Info.plist
Normal 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>
|
||||
15
FieldLogger/Platforms/iOS/Program.cs
Normal file
15
FieldLogger/Platforms/iOS/Program.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
51
FieldLogger/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
Normal file
51
FieldLogger/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
Normal 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>
|
||||
8
FieldLogger/Properties/launchSettings.json
Normal file
8
FieldLogger/Properties/launchSettings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Windows Machine": {
|
||||
"commandName": "Project",
|
||||
"nativeDebugging": false
|
||||
}
|
||||
}
|
||||
}
|
||||
4
FieldLogger/Resources/AppIcon/appicon.svg
Normal file
4
FieldLogger/Resources/AppIcon/appicon.svg
Normal 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 |
8
FieldLogger/Resources/AppIcon/appiconfg.svg
Normal file
8
FieldLogger/Resources/AppIcon/appiconfg.svg
Normal 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 |
BIN
FieldLogger/Resources/Fonts/OpenSans-Regular.ttf
Normal file
BIN
FieldLogger/Resources/Fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
BIN
FieldLogger/Resources/Fonts/OpenSans-Semibold.ttf
Normal file
BIN
FieldLogger/Resources/Fonts/OpenSans-Semibold.ttf
Normal file
Binary file not shown.
BIN
FieldLogger/Resources/Images/dotnet_bot.png
Normal file
BIN
FieldLogger/Resources/Images/dotnet_bot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
15
FieldLogger/Resources/Raw/AboutAssets.txt
Normal file
15
FieldLogger/Resources/Raw/AboutAssets.txt
Normal 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();
|
||||
}
|
||||
64
FieldLogger/Resources/Raw/map.html
Normal file
64
FieldLogger/Resources/Raw/map.html
Normal 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 + ' · ' + p.utility + '</b><br/>' +
|
||||
'Depth: ' + p.depth + ' · Current: ' + p.current + '<br/>' +
|
||||
'Frequency: ' + p.frequency + ' Hz<br/>' +
|
||||
'Fix: ' + p.fix + ' (±' + 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>
|
||||
8
FieldLogger/Resources/Splash/splash.svg
Normal file
8
FieldLogger/Resources/Splash/splash.svg
Normal 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 |
45
FieldLogger/Resources/Styles/Colors.xaml
Normal file
45
FieldLogger/Resources/Styles/Colors.xaml
Normal 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>
|
||||
444
FieldLogger/Resources/Styles/Styles.xaml
Normal file
444
FieldLogger/Resources/Styles/Styles.xaml
Normal 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>
|
||||
81
FieldLogger/Services/Ble/BleScanner.cs
Normal file
81
FieldLogger/Services/Ble/BleScanner.cs
Normal 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
|
||||
}
|
||||
}
|
||||
165
FieldLogger/Services/Ble/BleSerialClient.cs
Normal file
165
FieldLogger/Services/Ble/BleSerialClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
90
FieldLogger/Services/Data/AppDatabase.cs
Normal file
90
FieldLogger/Services/Data/AppDatabase.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
175
FieldLogger/Services/DeviceConnectionManager.cs
Normal file
175
FieldLogger/Services/DeviceConnectionManager.cs
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
98
FieldLogger/Services/MaglinkService.cs
Normal file
98
FieldLogger/Services/MaglinkService.cs
Normal 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();
|
||||
}
|
||||
67
FieldLogger/Services/PointLogger.cs
Normal file
67
FieldLogger/Services/PointLogger.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
58
FieldLogger/Services/SettingsService.cs
Normal file
58
FieldLogger/Services/SettingsService.cs
Normal 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;
|
||||
}
|
||||
27
FieldLogger/Services/Sync/IMqttSyncService.cs
Normal file
27
FieldLogger/Services/Sync/IMqttSyncService.cs
Normal 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;
|
||||
}
|
||||
88
FieldLogger/Services/UmReceiverService.cs
Normal file
88
FieldLogger/Services/UmReceiverService.cs
Normal 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();
|
||||
}
|
||||
107
FieldLogger/ViewModels/DeviceScanViewModel.cs
Normal file
107
FieldLogger/ViewModels/DeviceScanViewModel.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
149
FieldLogger/ViewModels/HomeViewModel.cs
Normal file
149
FieldLogger/ViewModels/HomeViewModel.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
59
FieldLogger/ViewModels/JobDetailViewModel.cs
Normal file
59
FieldLogger/ViewModels/JobDetailViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
104
FieldLogger/ViewModels/JobsViewModel.cs
Normal file
104
FieldLogger/ViewModels/JobsViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
53
FieldLogger/ViewModels/MapViewModel.cs
Normal file
53
FieldLogger/ViewModels/MapViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
52
FieldLogger/ViewModels/SettingsViewModel.cs
Normal file
52
FieldLogger/ViewModels/SettingsViewModel.cs
Normal 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");
|
||||
}
|
||||
44
FieldLogger/Views/DeviceScanPage.xaml
Normal file
44
FieldLogger/Views/DeviceScanPage.xaml
Normal 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>
|
||||
26
FieldLogger/Views/DeviceScanPage.xaml.cs
Normal file
26
FieldLogger/Views/DeviceScanPage.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
108
FieldLogger/Views/HomePage.xaml
Normal file
108
FieldLogger/Views/HomePage.xaml
Normal 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>
|
||||
29
FieldLogger/Views/HomePage.xaml.cs
Normal file
29
FieldLogger/Views/HomePage.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
77
FieldLogger/Views/JobDetailPage.xaml
Normal file
77
FieldLogger/Views/JobDetailPage.xaml
Normal 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>
|
||||
20
FieldLogger/Views/JobDetailPage.xaml.cs
Normal file
20
FieldLogger/Views/JobDetailPage.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
60
FieldLogger/Views/JobsPage.xaml
Normal file
60
FieldLogger/Views/JobsPage.xaml
Normal 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>
|
||||
20
FieldLogger/Views/JobsPage.xaml.cs
Normal file
20
FieldLogger/Views/JobsPage.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
13
FieldLogger/Views/MapPage.xaml
Normal file
13
FieldLogger/Views/MapPage.xaml
Normal 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>
|
||||
151
FieldLogger/Views/MapPage.xaml.cs
Normal file
151
FieldLogger/Views/MapPage.xaml.cs
Normal 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
|
||||
}
|
||||
64
FieldLogger/Views/SettingsPage.xaml
Normal file
64
FieldLogger/Views/SettingsPage.xaml
Normal 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>
|
||||
12
FieldLogger/Views/SettingsPage.xaml.cs
Normal file
12
FieldLogger/Views/SettingsPage.xaml.cs
Normal 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
122
README.md
Normal 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
181
doc/AT_Commands_Guide_EN.md
Normal 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
|
||||
544
doc/AT_Commands_Reference.md
Normal file
544
doc/AT_Commands_Reference.md
Normal 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
|
||||
122
doc/AT_UPLOADDATA_MQTT_Configuration_EN.md
Normal file
122
doc/AT_UPLOADDATA_MQTT_Configuration_EN.md
Normal 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
|
||||
```
|
||||
542
doc/Custom_NMEA_Sentences.md
Normal file
542
doc/Custom_NMEA_Sentences.md
Normal 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
|
||||
BIN
doc/UM Receiver BLE External Logging API v1.2 DRAFT.pdf
Normal file
BIN
doc/UM Receiver BLE External Logging API v1.2 DRAFT.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user