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