mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
4 Commits
v2026.0613
...
f7b6143a57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7b6143a57 | ||
|
|
3c08e150ef | ||
|
|
3283d4ca20 | ||
|
|
bdbceece75 |
18
README.md
18
README.md
@@ -75,6 +75,24 @@ It uses the MeshCore protocol over LoRa for long-range, infrastructure-free comm
|
||||
- Incident command and coordination roles
|
||||
- Operators working in weak/no cellular coverage
|
||||
|
||||
## Connecting Your Radio (BLE)
|
||||
|
||||
The app pairs with your MeshCore radio over Bluetooth Low Energy.
|
||||
|
||||
### Windows
|
||||
|
||||
On Windows 11 (tested on 25H2), pair the radio through the system Bluetooth menu **before** connecting in the app:
|
||||
|
||||
1. Open **Settings → Bluetooth & devices → Add device → Bluetooth**.
|
||||
2. Select your MeshCore radio and complete pairing, entering the **PIN** when prompted.
|
||||
3. Launch MeshCore SAR and connect — the radio now appears as a paired device.
|
||||
|
||||
If the radio does not show up or fails to connect in-app, remove it from the Windows Bluetooth menu and repeat the pairing step.
|
||||
|
||||
### iOS / Android
|
||||
|
||||
Connect directly from within the app — no separate system pairing step is required.
|
||||
|
||||
## Permissions (App Use)
|
||||
|
||||
- Bluetooth: mesh device communication
|
||||
|
||||
@@ -16,6 +16,24 @@ subprojects {
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
// flutter_avif_android 3.1.0 (latest) ships duplicate Java and Kotlin
|
||||
// copies of FlutterAvifPlugin. Kotlin 2.2+ rejects the redeclaration. The
|
||||
// plugin's Java source dir contains only this redundant copy, so empty it
|
||||
// and let the identical Kotlin implementation compile. kotlinc reads the
|
||||
// module's Java source roots for joint compilation, so clearing srcDirs
|
||||
// (rather than an exclude filter, which AGP doesn't forward to kotlinc) is
|
||||
// what actually removes the duplicate from its view. Registered before
|
||||
// evaluationDependsOn so it also covers :app, which that call evaluates
|
||||
// eagerly.
|
||||
afterEvaluate {
|
||||
if (project.name == "flutter_avif_android") {
|
||||
extensions.findByName("android")?.let { ext ->
|
||||
(ext as com.android.build.gradle.BaseExtension)
|
||||
.sourceSets.getByName("main").java
|
||||
.setSrcDirs(emptyList<String>())
|
||||
}
|
||||
}
|
||||
}
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
# Kotlin 2.2+ fails the build when a module's Java and Kotlin compile tasks
|
||||
# target different JVM versions. Several Flutter plugins still pin mismatched
|
||||
# targets (e.g. Java 1.8 vs Kotlin 1.8, or Java 11 vs Kotlin 17). On Android
|
||||
# every class is dexed by D8/R8, so the class-file version mismatch is benign;
|
||||
# downgrade the strict check to a warning so third-party plugins build.
|
||||
kotlin.jvm.target.validation.mode=warning
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.9.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
155
ios/Podfile.lock
155
ios/Podfile.lock
@@ -1,206 +1,51 @@
|
||||
PODS:
|
||||
- audioplayers_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- codec2_flutter (0.1.0):
|
||||
- Flutter
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- DKImagePickerController/Core (4.3.9):
|
||||
- DKImagePickerController/ImageDataManager
|
||||
- DKImagePickerController/Resource
|
||||
- DKImagePickerController/ImageDataManager (4.3.9)
|
||||
- DKImagePickerController/PhotoGallery (4.3.9):
|
||||
- DKImagePickerController/Core
|
||||
- DKPhotoGallery
|
||||
- DKImagePickerController/Resource (4.3.9)
|
||||
- DKPhotoGallery (0.0.19):
|
||||
- DKPhotoGallery/Core (= 0.0.19)
|
||||
- DKPhotoGallery/Model (= 0.0.19)
|
||||
- DKPhotoGallery/Preview (= 0.0.19)
|
||||
- DKPhotoGallery/Resource (= 0.0.19)
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Core (0.0.19):
|
||||
- DKPhotoGallery/Model
|
||||
- DKPhotoGallery/Preview
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Model (0.0.19):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Preview (0.0.19):
|
||||
- DKPhotoGallery/Model
|
||||
- DKPhotoGallery/Resource
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Resource (0.0.19):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- file_picker (0.0.1):
|
||||
- DKImagePickerController/PhotoGallery
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_avif_ios (0.0.1):
|
||||
- Flutter
|
||||
- flutter_background_service_ios (0.0.3):
|
||||
- Flutter
|
||||
- flutter_blue_plus_darwin (0.0.2):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- flutter_compass (0.0.1):
|
||||
- Flutter
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- geolocator_apple (1.2.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- nsd_ios (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- record_ios (1.2.0):
|
||||
- Flutter
|
||||
- SDWebImage (5.21.7):
|
||||
- SDWebImage/Core (= 5.21.7)
|
||||
- SDWebImage/Core (5.21.7)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- SwiftyGif (5.4.5)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- vibration (3.0.0):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
||||
- codec2_flutter (from `.symlinks/plugins/codec2_flutter/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_avif_ios (from `.symlinks/plugins/flutter_avif_ios/ios`)
|
||||
- flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`)
|
||||
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||
- flutter_compass (from `.symlinks/plugins/flutter_compass/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- vibration (from `.symlinks/plugins/vibration/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- DKImagePickerController
|
||||
- DKPhotoGallery
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audioplayers_darwin:
|
||||
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
|
||||
codec2_flutter:
|
||||
:path: ".symlinks/plugins/codec2_flutter/ios"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
file_picker:
|
||||
:path: ".symlinks/plugins/file_picker/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_avif_ios:
|
||||
:path: ".symlinks/plugins/flutter_avif_ios/ios"
|
||||
flutter_background_service_ios:
|
||||
:path: ".symlinks/plugins/flutter_background_service_ios/ios"
|
||||
flutter_blue_plus_darwin:
|
||||
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
||||
flutter_compass:
|
||||
:path: ".symlinks/plugins/flutter_compass/ios"
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
geolocator_apple:
|
||||
:path: ".symlinks/plugins/geolocator_apple/darwin"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
nsd_ios:
|
||||
:path: ".symlinks/plugins/nsd_ios/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
record_ios:
|
||||
:path: ".symlinks/plugins/record_ios/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
vibration:
|
||||
:path: ".symlinks/plugins/vibration/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
||||
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_avif_ios: 2553d2aafda56339cf924e943da89ad3f40af2dd
|
||||
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
||||
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
|
||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
B117D6FC7E214B5BDF078335 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E125ECD1538D6FD469DAD40 /* Pods_RunnerTests.framework */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -65,6 +66,7 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -80,6 +82,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
6E07C83B534F125C2CBE6788 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -111,6 +114,7 @@
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -187,6 +191,9 @@
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
@@ -213,6 +220,9 @@
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
@@ -489,7 +499,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -511,7 +521,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -530,7 +540,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -547,7 +557,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -679,7 +689,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -702,7 +712,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -752,6 +762,18 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -5,6 +5,24 @@
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
59
ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal file
59
ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>141</string>
|
||||
<string>142</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -5,12 +5,22 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000199">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000247">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: upload_to_app_store" time="37.324492">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.741448">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="110.585223">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="353.311697">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -196,6 +196,54 @@ class ContactRouteCodec {
|
||||
final hopCount = raw & 0x3F;
|
||||
return hopCount * hashSize <= maxPathBytes;
|
||||
}
|
||||
|
||||
/// Converts an encoded MeshCore route descriptor + path into the legacy raw
|
||||
/// path format expected by the `CMD_SEND_RAW_DATA` (0x19) handler in
|
||||
/// companion firmware v1.15.
|
||||
///
|
||||
/// The raw-data handler treats the path-length byte as a *literal* hop count
|
||||
/// (one byte per hop) rather than as an encoded descriptor, so a 1-hop route
|
||||
/// using 2-byte hashes (descriptor `0x41`) would otherwise be misread as
|
||||
/// "65 path bytes" and rejected with `ERROR: Unsupported command`. We
|
||||
/// down-sample every hop to its first hash byte and emit a plain hop count.
|
||||
///
|
||||
/// Routes that are already legacy (hash size 1, descriptor `< 0x40`) pass
|
||||
/// through unchanged. Unknown/flood descriptors collapse to a zero-hop
|
||||
/// direct path (raw transport does not support flood routing).
|
||||
static LegacyRawPath toLegacyRawPath(int descriptor, Uint8List path) {
|
||||
final raw = toUnsignedDescriptor(descriptor);
|
||||
if (raw == _unknownDescriptor) {
|
||||
return const LegacyRawPath(length: 0, path: <int>[]);
|
||||
}
|
||||
if (raw < 0x40) {
|
||||
// Hash size 1: the encoded path is already one byte per hop.
|
||||
final hopCount = raw;
|
||||
final available = math.min(hopCount, path.length);
|
||||
return LegacyRawPath(
|
||||
length: hopCount,
|
||||
path: Uint8List.fromList(path.sublist(0, available)),
|
||||
);
|
||||
}
|
||||
final hopCount = raw & 0x3F;
|
||||
final hashSize = ((raw >> 6) & 0x03) + 1;
|
||||
final legacy = Uint8List(hopCount);
|
||||
for (var hop = 0; hop < hopCount; hop++) {
|
||||
final srcIndex = hop * hashSize;
|
||||
if (srcIndex < path.length) {
|
||||
legacy[hop] = path[srcIndex];
|
||||
}
|
||||
}
|
||||
return LegacyRawPath(length: hopCount, path: legacy);
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy raw-transport path: a literal hop count plus one byte per hop, as
|
||||
/// expected by the firmware `CMD_SEND_RAW_DATA` handler.
|
||||
class LegacyRawPath {
|
||||
const LegacyRawPath({required this.length, required this.path});
|
||||
|
||||
final int length;
|
||||
final List<int> path;
|
||||
}
|
||||
|
||||
extension ContactLocalization on Contact {
|
||||
|
||||
@@ -147,6 +147,22 @@ class ChannelLocationSharingResult {
|
||||
});
|
||||
}
|
||||
|
||||
/// Radio fast-GPS region scope, mirroring the firmware's `fast_gps_region`
|
||||
/// custom var. Index 0 is unscoped (flood everywhere); higher indices restrict
|
||||
/// beacons to a named region. `labels` comes from the firmware so the picker
|
||||
/// always matches the regions that build supports.
|
||||
class FastGpsRegionState {
|
||||
final List<String> labels;
|
||||
final int selectedIndex;
|
||||
|
||||
const FastGpsRegionState({required this.labels, required this.selectedIndex});
|
||||
|
||||
String get selectedLabel =>
|
||||
(selectedIndex >= 0 && selectedIndex < labels.length)
|
||||
? labels[selectedIndex]
|
||||
: (labels.isNotEmpty ? labels.first : 'Unscoped');
|
||||
}
|
||||
|
||||
/// Main App Provider - coordinates all other providers
|
||||
class AppProvider with ChangeNotifier {
|
||||
static const int _maxDirectPayloadHops = 3;
|
||||
@@ -981,9 +997,17 @@ class AppProvider with ChangeNotifier {
|
||||
);
|
||||
};
|
||||
|
||||
locationTrackingService.onFastLocationUpdate = (position, reason) {
|
||||
unawaited(_sendFastLocationUpdate(position, reason: reason));
|
||||
};
|
||||
locationTrackingService.onFastLocationUpdate =
|
||||
(latitude, longitude, speedKmh, reason) {
|
||||
unawaited(
|
||||
_sendFastLocationUpdate(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
speedKmh: speedKmh,
|
||||
reason: reason,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
debugPrint('✅ [AppProvider] Location tracking service initialized');
|
||||
} catch (e) {
|
||||
@@ -2903,6 +2927,34 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the radio's fast-GPS region scope, or `null` when the firmware does
|
||||
/// not expose region scoping (older builds only know `fast_gps_channel`).
|
||||
Future<FastGpsRegionState?> getFastGpsRegionState() async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
return null;
|
||||
}
|
||||
final vars = await connectionProvider.getCustomVars();
|
||||
final labelsRaw = vars['fast_gps_regions'];
|
||||
if (labelsRaw == null || labelsRaw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final labels = labelsRaw.split(',');
|
||||
final index = int.tryParse(vars['fast_gps_region'] ?? '0') ?? 0;
|
||||
return FastGpsRegionState(
|
||||
labels: labels,
|
||||
selectedIndex: (index >= 0 && index < labels.length) ? index : 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Sets the radio's fast-GPS region scope by index (0 = unscoped).
|
||||
Future<void> setFastGpsRegion(int index) async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
throw StateError('Connect to a device first');
|
||||
}
|
||||
await _setDeviceCustomVarOrThrow('fast_gps_region', index.toString());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<ChannelLocationSharingState> getChannelLocationSharingState(
|
||||
int channelIdx,
|
||||
) async {
|
||||
@@ -3074,8 +3126,10 @@ class AppProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendFastLocationUpdate(
|
||||
dynamic position, {
|
||||
Future<void> _sendFastLocationUpdate({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
required int speedKmh,
|
||||
required String reason,
|
||||
}) async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
@@ -3108,20 +3162,21 @@ class AppProvider with ChangeNotifier {
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final clampedSpeedKmh = speedKmh.clamp(0, 255);
|
||||
final packet = FastGpsPacket(
|
||||
senderKey6: senderKey6,
|
||||
latitude: position.latitude as double,
|
||||
longitude: position.longitude as double,
|
||||
timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
speedKmh: clampedSpeedKmh,
|
||||
);
|
||||
debugPrint(
|
||||
'📤 [AppProvider] Fast GPS send '
|
||||
'reason=$reason '
|
||||
'sender=$senderKey6 '
|
||||
'channel=$channelIdx '
|
||||
'lat=${position.latitude} '
|
||||
'lon=${position.longitude} '
|
||||
'ts=${packet.timestampSeconds}',
|
||||
'lat=$latitude '
|
||||
'lon=$longitude '
|
||||
'speed=${clampedSpeedKmh}km/h',
|
||||
);
|
||||
try {
|
||||
await connectionProvider.sendChannelData(
|
||||
@@ -3131,7 +3186,7 @@ class AppProvider with ChangeNotifier {
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [AppProvider] Fast GPS sent '
|
||||
'sender=$senderKey6 channel=$channelIdx ts=${packet.timestampSeconds}',
|
||||
'sender=$senderKey6 channel=$channelIdx speed=${speedKmh}km/h',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
|
||||
@@ -3151,7 +3206,17 @@ class AppProvider with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _sendFastLocationUpdate(position, reason: 'test');
|
||||
// Geolocator reports ground speed in m/s; the beacon carries km/h (0..255).
|
||||
final speedMs = position.speed;
|
||||
final speedKmh = (speedMs.isFinite && speedMs > 0)
|
||||
? (speedMs * 3.6).round().clamp(0, 255)
|
||||
: 0;
|
||||
await _sendFastLocationUpdate(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
speedKmh: speedKmh,
|
||||
reason: 'test',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3693,6 +3758,10 @@ class AppProvider with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Heard a peer beacon — yield the channel briefly before our own send, to
|
||||
// avoid collisions (mirrors the firmware's post-RX hold-off).
|
||||
locationTrackingService.noteFastGpsBeaconHeard();
|
||||
|
||||
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
|
||||
if (sender != null) {
|
||||
contactsProvider.updateFastGps(
|
||||
|
||||
@@ -1833,15 +1833,23 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
/// Send a raw binary voice packet directly to a contact (cmdSendRawData, code 25).
|
||||
/// Only works for contacts with a known direct route (outPathLen >= 0).
|
||||
///
|
||||
/// Callers pass the contact's *encoded* route descriptor ([routeEncodedPathLen])
|
||||
/// and encoded [outPath]. The `CMD_SEND_RAW_DATA` handler in companion firmware
|
||||
/// v1.15 misinterprets encoded descriptors as a literal path-byte count, so we
|
||||
/// normalise to the legacy raw path format (literal hop count + one byte per
|
||||
/// hop) before handing the frame to the transport. See
|
||||
/// [ContactRouteCodec.toLegacyRawPath].
|
||||
Future<void> sendRawVoicePacket({
|
||||
required Uint8List contactPath,
|
||||
required int contactPathLen,
|
||||
required Uint8List payload,
|
||||
}) async {
|
||||
if (!_activeService.isConnected) return;
|
||||
final legacy = ContactRouteCodec.toLegacyRawPath(contactPathLen, contactPath);
|
||||
await _activeService.sendRawVoicePacket(
|
||||
contactPathLen: contactPathLen,
|
||||
contactPath: contactPath,
|
||||
contactPathLen: legacy.length,
|
||||
contactPath: Uint8List.fromList(legacy.path),
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1164,6 +1164,10 @@ class ContactsProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// The fast-GPS beacon carries no timestamp — stamp our own receive time.
|
||||
final receivedAt = DateTime.now();
|
||||
final receivedAtSeconds = receivedAt.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
final updatedTelemetry = _mergeTelemetryForContact(
|
||||
existingTelemetry: contact.telemetry,
|
||||
incomingTelemetry: ContactTelemetry(
|
||||
@@ -1171,9 +1175,7 @@ class ContactsProvider with ChangeNotifier {
|
||||
batteryPercentage: null,
|
||||
batteryMilliVolts: null,
|
||||
temperature: null,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
packet.timestampSeconds * 1000,
|
||||
),
|
||||
timestamp: receivedAt,
|
||||
humidity: null,
|
||||
pressure: null,
|
||||
extraSensorData: null,
|
||||
@@ -1187,13 +1189,13 @@ class ContactsProvider with ChangeNotifier {
|
||||
'contactKey=${contact.publicKeyHex} '
|
||||
'lat=${packet.latitude} '
|
||||
'lon=${packet.longitude} '
|
||||
'ts=${packet.timestampSeconds}',
|
||||
'speed=${packet.speedKmh}km/h',
|
||||
);
|
||||
|
||||
final updatedContact = contact.copyWith(
|
||||
telemetry: updatedTelemetry,
|
||||
lastAdvert: packet.timestampSeconds,
|
||||
lastMod: packet.timestampSeconds,
|
||||
lastAdvert: receivedAtSeconds,
|
||||
lastMod: receivedAtSeconds,
|
||||
advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
|
||||
advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,11 @@ typedef RawPacketSender =
|
||||
required Uint8List payload,
|
||||
});
|
||||
|
||||
/// Pacing between consecutive raw fragments. Firmware v1.15 rejects bursts of
|
||||
/// raw packets with `ERROR: Table full` when fragments are blasted too quickly,
|
||||
/// so we space them out to keep the radio's raw transmit queue from overflowing.
|
||||
const Duration _interFragmentDelay = Duration(milliseconds: 350);
|
||||
|
||||
Future<bool> serveCachedSessionFragments<T>({
|
||||
required String providerLabel,
|
||||
required String sessionId,
|
||||
@@ -19,6 +24,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
required Uint8List Function(T fragment) encodeBinary,
|
||||
required RawPacketSender? sendRawPacket,
|
||||
Set<int>? requestedIndices,
|
||||
Duration interFragmentDelay = _interFragmentDelay,
|
||||
}) async {
|
||||
if (fragments.isEmpty) {
|
||||
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
||||
@@ -56,6 +62,9 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (servedCount > 0 && interFragmentDelay > Duration.zero) {
|
||||
await Future<void>.delayed(interFragmentDelay);
|
||||
}
|
||||
await sendRawPacket(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.routeEncodedPathLen,
|
||||
|
||||
@@ -85,6 +85,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
double _fastLocationMovementThresholdMeters = 10.0;
|
||||
int _fastLocationActiveCadenceSeconds = 10;
|
||||
int? _fastLocationChannelIdx;
|
||||
FastGpsRegionState? _fastGpsRegionState;
|
||||
bool _rotateMapWithHeading = false;
|
||||
bool _showMapDebugInfo = false;
|
||||
bool _openMapInFullscreen = false;
|
||||
@@ -410,6 +411,58 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_locationService.fastLocationActiveCadenceSeconds;
|
||||
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
|
||||
});
|
||||
await _loadFastGpsRegionState();
|
||||
}
|
||||
|
||||
Future<void> _loadFastGpsRegionState() async {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
FastGpsRegionState? regionState;
|
||||
try {
|
||||
regionState = await appProvider.getFastGpsRegionState();
|
||||
} catch (_) {
|
||||
regionState = null;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _fastGpsRegionState = regionState);
|
||||
}
|
||||
|
||||
Future<void> _editFastGpsRegion() async {
|
||||
final regionState = _fastGpsRegionState;
|
||||
if (regionState == null) return;
|
||||
|
||||
final selected = await showModalBottomSheet<int>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < regionState.labels.length; i++)
|
||||
ListTile(
|
||||
leading: Icon(i == 0 ? Icons.public : Icons.travel_explore),
|
||||
title: Text(regionState.labels[i]),
|
||||
trailing: regionState.selectedIndex == i
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () => Navigator.pop(sheetContext, i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (selected == null || selected == regionState.selectedIndex) return;
|
||||
try {
|
||||
await context.read<AppProvider>().setFastGpsRegion(selected);
|
||||
await _loadFastGpsRegionState();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to set region: $e'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setFastLocationUpdatesEnabled(bool enabled) async {
|
||||
@@ -1766,6 +1819,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _editFastLocationChannel,
|
||||
),
|
||||
if (_fastGpsRegionState != null)
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.travel_explore, size: 20),
|
||||
title: const Text('Region scope'),
|
||||
subtitle: Text(_fastGpsRegionState!.selectedLabel),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _editFastGpsRegion,
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.send, size: 20),
|
||||
|
||||
@@ -25,15 +25,43 @@ class LocationTrackingService {
|
||||
static const int _defaultFastLocationActiveCadenceSeconds = 60;
|
||||
static const int _minFastLocationActiveCadenceSeconds = 60;
|
||||
static const int _maxFastLocationActiveCadenceSeconds = 60;
|
||||
static const Duration _fastLocationSlowInterval = Duration(
|
||||
seconds: 60,
|
||||
);
|
||||
// Moving cadence tiers — identical to the MeshUI firmware
|
||||
// (FAST_GPS_*_INTERVAL_MS / FAST_GPS_SPEED_*_MAX_MPS in MyMesh.cpp).
|
||||
static const Duration _fastLocationWalkingInterval = Duration(seconds: 30);
|
||||
static const Duration _fastLocationFastInterval = Duration(seconds: 15);
|
||||
static const Duration _fastLocationVeryFastInterval = Duration(seconds: 5);
|
||||
static const double _fastLocationIdleSpeedMaxMetersPerSecond = 0.75;
|
||||
static const double _fastLocationWalkingSpeedMaxMetersPerSecond = 1.8;
|
||||
static const double _fastLocationFastSpeedMaxMetersPerSecond = 4.0;
|
||||
|
||||
// Stationary keepalive — a parked node still beacons, but only every 9 min
|
||||
// (FAST_GPS_STATIONARY_*_INTERVAL_MS), not on GPS jitter.
|
||||
static const Duration _fastLocationStationaryInterval = Duration(minutes: 9);
|
||||
|
||||
// Moving/stationary detection: anchor + dwell hysteresis (position-only, no
|
||||
// Doppler), mirroring FAST_GPS_MOVE_* in MyMesh.cpp. A parked node holds an
|
||||
// anchor; jitter inside the radius is absorbed and the stable anchor is
|
||||
// reported, so multipath wander can't paint a fake track.
|
||||
static const double _fastLocationMoveRadiusMeters = 25.0;
|
||||
static const Duration _fastLocationMoveDwell = Duration(seconds: 15);
|
||||
static const Duration _fastLocationStopDwell = Duration(seconds: 60);
|
||||
static const Duration _fastLocationMoveEval = Duration(seconds: 1);
|
||||
static const double _fastLocationSpeedGateMeters = 5.0;
|
||||
static const Duration _fastLocationSpeedRebaseline = Duration(seconds: 8);
|
||||
static const double _fastLocationSpeedGlitchMaxKmh = 300.0;
|
||||
|
||||
// Fix-quality gate. The firmware requires >= 5 satellites (FAST_GPS_MIN_SATS);
|
||||
// Geolocator does not expose a satellite count on all platforms, so we gate on
|
||||
// horizontal accuracy as the closest available proxy.
|
||||
static const double _fastLocationMaxAccuracyMeters = 50.0;
|
||||
|
||||
// Yield ~5s after hearing a peer beacon, to avoid channel collisions
|
||||
// (FAST_GPS_CHANNEL_RX_HOLDOFF_MS).
|
||||
static const Duration _fastLocationRxHoldoff = Duration(seconds: 5);
|
||||
|
||||
// Background keepalive tick when not in active use — cheap, just enough to fire
|
||||
// the 9-min stationary beacon while parked.
|
||||
static const Duration _fastLocationActiveTick = Duration(seconds: 15);
|
||||
// ============================================================================
|
||||
// Singleton Pattern
|
||||
// ============================================================================
|
||||
@@ -128,7 +156,22 @@ class LocationTrackingService {
|
||||
Timer? _fastLocationTimer;
|
||||
bool _isFastLocationActiveUse = false;
|
||||
DateTime? _lastFastLocationSentAt;
|
||||
Position? _lastFastLocationSentPosition;
|
||||
int? _lastFastLocationSentLatE6;
|
||||
int? _lastFastLocationSentLonE6;
|
||||
|
||||
// Firmware-mirrored fast-GPS motion state (app-fallback path). See
|
||||
// MyMesh::updateGpsStatusCache / maybeSendFastGpsUpdate.
|
||||
int? _fastGpsAnchorLatE6;
|
||||
int? _fastGpsAnchorLonE6;
|
||||
bool _fastGpsRefValid = false;
|
||||
bool _fastGpsIsMoving = false;
|
||||
DateTime? _fastGpsMoveStateSince;
|
||||
DateTime? _fastGpsMoveEvalAt;
|
||||
double _fastGpsSpeedKmh = 0.0;
|
||||
int? _fastGpsSpeedPrevLatE6;
|
||||
int? _fastGpsSpeedPrevLonE6;
|
||||
DateTime? _fastGpsSpeedPrevAt;
|
||||
DateTime? _fastGpsRxHoldoffUntil;
|
||||
|
||||
// ============================================================================
|
||||
// Callback Properties
|
||||
@@ -146,8 +189,11 @@ class LocationTrackingService {
|
||||
/// Called when tracking state changes
|
||||
void Function(bool isTracking)? onTrackingStateChanged;
|
||||
|
||||
/// Called when a fast private GPS update should be sent
|
||||
void Function(Position position, String reason)? onFastLocationUpdate;
|
||||
/// Called when a fast private GPS update should be sent. Carries the resolved
|
||||
/// beacon coordinates (de-jittered anchor when parked) and EMA ground speed in
|
||||
/// km/h, so the caller just encodes and transmits.
|
||||
void Function(double latitude, double longitude, int speedKmh, String reason)?
|
||||
onFastLocationUpdate;
|
||||
|
||||
// ============================================================================
|
||||
// Initialization
|
||||
@@ -418,6 +464,7 @@ class LocationTrackingService {
|
||||
isTracking = false;
|
||||
onTrackingStateChanged?.call(false);
|
||||
_refreshFastLocationTimer();
|
||||
_resetFastGpsMotionState();
|
||||
|
||||
// Reset first position flag so next connection starts fresh
|
||||
_firstPositionSet = false;
|
||||
@@ -533,6 +580,9 @@ class LocationTrackingService {
|
||||
|
||||
Future<void> setFastLocationUpdatesEnabled(bool enabled) async {
|
||||
fastLocationUpdatesEnabled = enabled;
|
||||
if (!enabled) {
|
||||
_resetFastGpsMotionState();
|
||||
}
|
||||
await saveSettings();
|
||||
_refreshFastLocationTimer();
|
||||
}
|
||||
@@ -560,23 +610,35 @@ class LocationTrackingService {
|
||||
_refreshFastLocationTimer();
|
||||
}
|
||||
|
||||
/// Arms the post-RX send hold-off after hearing a peer's fast-GPS beacon, so
|
||||
/// the phone yields the channel briefly to avoid collisions
|
||||
/// (FAST_GPS_CHANNEL_RX_HOLDOFF_MS). Called by the app's incoming-beacon path.
|
||||
void noteFastGpsBeaconHeard() {
|
||||
_fastGpsRxHoldoffUntil = DateTime.now().add(_fastLocationRxHoldoff);
|
||||
}
|
||||
|
||||
/// Clears all fast-GPS motion/anchor state. Mirrors resetFastGpsShareState.
|
||||
void _resetFastGpsMotionState() {
|
||||
_lastFastLocationSentAt = null;
|
||||
_lastFastLocationSentLatE6 = null;
|
||||
_lastFastLocationSentLonE6 = null;
|
||||
_fastGpsAnchorLatE6 = null;
|
||||
_fastGpsAnchorLonE6 = null;
|
||||
_fastGpsRefValid = false;
|
||||
_fastGpsIsMoving = false;
|
||||
_fastGpsMoveStateSince = null;
|
||||
_fastGpsMoveEvalAt = null;
|
||||
_fastGpsSpeedKmh = 0.0;
|
||||
_fastGpsSpeedPrevLatE6 = null;
|
||||
_fastGpsSpeedPrevLonE6 = null;
|
||||
_fastGpsSpeedPrevAt = null;
|
||||
_fastGpsRxHoldoffUntil = null;
|
||||
}
|
||||
|
||||
void _evaluateFastLocationMovement(Position position) {
|
||||
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
|
||||
final previous = _lastFastLocationSentPosition;
|
||||
if (previous == null) {
|
||||
_emitFastLocationUpdate(position, reason: 'initial');
|
||||
return;
|
||||
}
|
||||
|
||||
final distance = Geolocator.distanceBetween(
|
||||
previous.latitude,
|
||||
previous.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
if (distance >= fastLocationMovementThresholdMeters) {
|
||||
_emitFastLocationUpdate(position, reason: 'movement');
|
||||
}
|
||||
_updateFastGpsMotionState(position);
|
||||
_maybeSendFastLocation();
|
||||
}
|
||||
|
||||
void _refreshFastLocationTimer() {
|
||||
@@ -584,75 +646,213 @@ class LocationTrackingService {
|
||||
_fastLocationTimer = null;
|
||||
if (!isTracking ||
|
||||
!fastLocationUpdatesEnabled ||
|
||||
fastLocationChannelIdx == null ||
|
||||
!_isFastLocationActiveUse) {
|
||||
fastLocationChannelIdx == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_fastLocationTimer = Timer.periodic(
|
||||
Duration(seconds: fastLocationActiveCadenceSeconds),
|
||||
(_) {
|
||||
final position = currentPosition;
|
||||
if (position == null) return;
|
||||
_emitFastLocationUpdate(position, reason: 'active_use');
|
||||
},
|
||||
// Always tick (even when parked) so the stationary keepalive fires without a
|
||||
// position-stream event. Tick fast while in active use; otherwise just often
|
||||
// enough to drive the 9-min keepalive cheaply.
|
||||
final period = _isFastLocationActiveUse
|
||||
? _fastLocationActiveTick
|
||||
: _fastLocationStationaryInterval;
|
||||
_fastLocationTimer = Timer.periodic(period, (_) => _maybeSendFastLocation());
|
||||
}
|
||||
|
||||
/// Updates the EMA ground speed and the moving/stationary anchor state machine
|
||||
/// from a fresh fix. Faithful port of MyMesh::updateGpsStatusCache.
|
||||
void _updateFastGpsMotionState(Position position) {
|
||||
// Fix-quality gate (firmware: satellitesCount >= FAST_GPS_MIN_SATS).
|
||||
final accuracy = position.accuracy;
|
||||
if (accuracy.isFinite &&
|
||||
accuracy > 0 &&
|
||||
accuracy > _fastLocationMaxAccuracyMeters) {
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final latE6 = (position.latitude * 1e6).round();
|
||||
final lonE6 = (position.longitude * 1e6).round();
|
||||
|
||||
// Ground speed from successive fixes, EMA-smoothed (0.4 prev / 0.6 inst).
|
||||
if (_fastGpsSpeedPrevLatE6 == null || _fastGpsSpeedPrevAt == null) {
|
||||
_fastGpsSpeedPrevLatE6 = latE6;
|
||||
_fastGpsSpeedPrevLonE6 = lonE6;
|
||||
_fastGpsSpeedPrevAt = now;
|
||||
} else {
|
||||
final distM = _distanceE6(
|
||||
_fastGpsSpeedPrevLatE6!,
|
||||
_fastGpsSpeedPrevLonE6!,
|
||||
latE6,
|
||||
lonE6,
|
||||
);
|
||||
final dtMs = now.difference(_fastGpsSpeedPrevAt!).inMilliseconds;
|
||||
if (distM >= _fastLocationSpeedGateMeters && dtMs >= 1000) {
|
||||
final instKmh = (distM / 1000.0) / (dtMs / 3600000.0);
|
||||
if (instKmh <= _fastLocationSpeedGlitchMaxKmh) {
|
||||
_fastGpsSpeedKmh = _fastGpsSpeedKmh * 0.4 + instKmh * 0.6;
|
||||
}
|
||||
_fastGpsSpeedPrevLatE6 = latE6;
|
||||
_fastGpsSpeedPrevLonE6 = lonE6;
|
||||
_fastGpsSpeedPrevAt = now;
|
||||
} else if (dtMs >= _fastLocationSpeedRebaseline.inMilliseconds) {
|
||||
_fastGpsSpeedKmh = 0.0;
|
||||
_fastGpsSpeedPrevLatE6 = latE6;
|
||||
_fastGpsSpeedPrevLonE6 = lonE6;
|
||||
_fastGpsSpeedPrevAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
// Moving/stationary state machine (anchor + dwell hysteresis), ~1 Hz.
|
||||
final evalAt = _fastGpsMoveEvalAt;
|
||||
if (evalAt != null && now.difference(evalAt) < _fastLocationMoveEval) {
|
||||
return;
|
||||
}
|
||||
_fastGpsMoveEvalAt = now;
|
||||
|
||||
if (!_fastGpsRefValid) {
|
||||
_fastGpsAnchorLatE6 = latE6;
|
||||
_fastGpsAnchorLonE6 = lonE6;
|
||||
_fastGpsRefValid = true;
|
||||
_fastGpsMoveStateSince = null;
|
||||
_fastGpsIsMoving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final refDist = _distanceE6(
|
||||
_fastGpsAnchorLatE6!,
|
||||
_fastGpsAnchorLonE6!,
|
||||
latE6,
|
||||
lonE6,
|
||||
);
|
||||
if (!_fastGpsIsMoving) {
|
||||
if (refDist > _fastLocationMoveRadiusMeters) {
|
||||
if (_fastGpsMoveStateSince == null) {
|
||||
_fastGpsMoveStateSince = now;
|
||||
} else if (now.difference(_fastGpsMoveStateSince!) >=
|
||||
_fastLocationMoveDwell) {
|
||||
_fastGpsIsMoving = true;
|
||||
_fastGpsAnchorLatE6 = latE6;
|
||||
_fastGpsAnchorLonE6 = lonE6;
|
||||
_fastGpsMoveStateSince = null;
|
||||
}
|
||||
} else {
|
||||
_fastGpsMoveStateSince = null;
|
||||
// Track slow GPS bias so cumulative drift never reaches the radius.
|
||||
_fastGpsAnchorLatE6 =
|
||||
_fastGpsAnchorLatE6! + ((latE6 - _fastGpsAnchorLatE6!) ~/ 8);
|
||||
_fastGpsAnchorLonE6 =
|
||||
_fastGpsAnchorLonE6! + ((lonE6 - _fastGpsAnchorLonE6!) ~/ 8);
|
||||
}
|
||||
} else {
|
||||
if (refDist > _fastLocationMoveRadiusMeters) {
|
||||
_fastGpsAnchorLatE6 = latE6;
|
||||
_fastGpsAnchorLonE6 = lonE6;
|
||||
_fastGpsMoveStateSince = null;
|
||||
} else if (_fastGpsMoveStateSince == null) {
|
||||
_fastGpsMoveStateSince = now;
|
||||
} else if (now.difference(_fastGpsMoveStateSince!) >=
|
||||
_fastLocationStopDwell) {
|
||||
_fastGpsIsMoving = false;
|
||||
_fastGpsAnchorLatE6 = latE6;
|
||||
_fastGpsAnchorLonE6 = lonE6;
|
||||
_fastGpsMoveStateSince = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides whether to emit a beacon now, mirroring maybeSendFastGpsUpdate:
|
||||
/// moving → speed-tier cadence once past the movement threshold; parked →
|
||||
/// flat 9-min keepalive of the stable anchor. Honours the RX hold-off.
|
||||
void _maybeSendFastLocation() {
|
||||
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
|
||||
final position = currentPosition;
|
||||
if (position == null) return;
|
||||
// No valid anchor yet means no good fix has landed — don't beacon (the
|
||||
// firmware likewise bails until it has a usable fix).
|
||||
if (!_fastGpsRefValid) return;
|
||||
|
||||
final int reportLatE6;
|
||||
final int reportLonE6;
|
||||
if (_fastGpsIsMoving) {
|
||||
reportLatE6 = (position.latitude * 1e6).round();
|
||||
reportLonE6 = (position.longitude * 1e6).round();
|
||||
} else {
|
||||
// Parked: report the stable anchor, not the wander.
|
||||
reportLatE6 = _fastGpsAnchorLatE6!;
|
||||
reportLonE6 = _fastGpsAnchorLonE6!;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final lastAt = _lastFastLocationSentAt;
|
||||
bool shouldSend = lastAt == null || _lastFastLocationSentLatE6 == null;
|
||||
String reason = 'initial';
|
||||
if (!shouldSend) {
|
||||
if (_fastGpsIsMoving) {
|
||||
final distM = _distanceE6(
|
||||
_lastFastLocationSentLatE6!,
|
||||
_lastFastLocationSentLonE6!,
|
||||
reportLatE6,
|
||||
reportLonE6,
|
||||
);
|
||||
if (distM > fastLocationMovementThresholdMeters) {
|
||||
final elapsed = now.difference(lastAt!);
|
||||
final interval = _movingIntervalForSpeedKmh(_fastGpsSpeedKmh);
|
||||
shouldSend = elapsed >= interval;
|
||||
reason = 'movement';
|
||||
}
|
||||
} else if (now.difference(lastAt!) >= _fastLocationStationaryInterval) {
|
||||
shouldSend = true;
|
||||
reason = 'stationary';
|
||||
}
|
||||
}
|
||||
if (!shouldSend) return;
|
||||
|
||||
// Yield the channel briefly after hearing a peer beacon.
|
||||
final holdoff = _fastGpsRxHoldoffUntil;
|
||||
if (holdoff != null) {
|
||||
if (now.isBefore(holdoff)) return;
|
||||
_fastGpsRxHoldoffUntil = null;
|
||||
}
|
||||
|
||||
_lastFastLocationSentLatE6 = reportLatE6;
|
||||
_lastFastLocationSentLonE6 = reportLonE6;
|
||||
_lastFastLocationSentAt = now;
|
||||
|
||||
final speedKmh = _fastGpsSpeedKmh < 0
|
||||
? 0
|
||||
: (_fastGpsSpeedKmh > 255.0 ? 255 : (_fastGpsSpeedKmh + 0.5).floor());
|
||||
onFastLocationUpdate?.call(
|
||||
reportLatE6 / 1e6,
|
||||
reportLonE6 / 1e6,
|
||||
speedKmh,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
void _emitFastLocationUpdate(Position position, {required String reason}) {
|
||||
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
final previous = _lastFastLocationSentPosition;
|
||||
final previousTime = _lastFastLocationSentAt;
|
||||
if (previous != null && previousTime != null) {
|
||||
final distance = Geolocator.distanceBetween(
|
||||
previous.latitude,
|
||||
previous.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
final elapsedMs = now.difference(previousTime).inMilliseconds;
|
||||
final minimumInterval = _fastLocationMinimumIntervalFor(
|
||||
distanceMeters: distance,
|
||||
elapsedMs: elapsedMs,
|
||||
);
|
||||
if (elapsedMs < minimumInterval.inMilliseconds) {
|
||||
return;
|
||||
}
|
||||
if (distance < 1.0 && elapsedMs < 3000) {
|
||||
return;
|
||||
}
|
||||
} else if (previousTime != null &&
|
||||
now.difference(previousTime) < _fastLocationSlowInterval) {
|
||||
return;
|
||||
Duration _movingIntervalForSpeedKmh(double speedKmh) {
|
||||
final speedMps = speedKmh / 3.6;
|
||||
if (speedMps < _fastLocationIdleSpeedMaxMetersPerSecond) {
|
||||
return _fastLocationStationaryInterval;
|
||||
}
|
||||
|
||||
_lastFastLocationSentPosition = position;
|
||||
_lastFastLocationSentAt = now;
|
||||
onFastLocationUpdate?.call(position, reason);
|
||||
}
|
||||
|
||||
Duration _fastLocationMinimumIntervalFor({
|
||||
required double distanceMeters,
|
||||
required int elapsedMs,
|
||||
}) {
|
||||
if (elapsedMs <= 0) {
|
||||
return _fastLocationSlowInterval;
|
||||
}
|
||||
final speedMetersPerSecond = distanceMeters / (elapsedMs / 1000.0);
|
||||
if (speedMetersPerSecond < _fastLocationIdleSpeedMaxMetersPerSecond) {
|
||||
return _fastLocationSlowInterval;
|
||||
}
|
||||
if (speedMetersPerSecond < _fastLocationWalkingSpeedMaxMetersPerSecond) {
|
||||
if (speedMps < _fastLocationWalkingSpeedMaxMetersPerSecond) {
|
||||
return _fastLocationWalkingInterval;
|
||||
}
|
||||
if (speedMetersPerSecond < _fastLocationFastSpeedMaxMetersPerSecond) {
|
||||
if (speedMps < _fastLocationFastSpeedMaxMetersPerSecond) {
|
||||
return _fastLocationFastInterval;
|
||||
}
|
||||
return _fastLocationVeryFastInterval;
|
||||
}
|
||||
|
||||
double _distanceE6(int latA, int lonA, int latB, int lonB) {
|
||||
return Geolocator.distanceBetween(
|
||||
latA / 1e6,
|
||||
lonA / 1e6,
|
||||
latB / 1e6,
|
||||
lonB / 1e6,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mesh Network Broadcasting
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Fast-GPS position beacon, wire-compatible with the MeshUI firmware
|
||||
/// (`maybeSendFastGpsUpdate` in MyMesh.cpp).
|
||||
///
|
||||
/// 16-byte binary payload sent as a `DATA_TYPE_DEV` group datagram on a
|
||||
/// non-public channel:
|
||||
///
|
||||
/// ```
|
||||
/// [0] magic 0x47 ('G')
|
||||
/// [1..6] sender public-key prefix (6 bytes)
|
||||
/// [7..10] latitude in microdegrees (int32, little-endian)
|
||||
/// [11..14] longitude in microdegrees (int32, little-endian)
|
||||
/// [15] ground speed in km/h (uint8, clamped 0..255)
|
||||
/// ```
|
||||
///
|
||||
/// The beacon carries no timestamp — receivers stamp their own RX time.
|
||||
class FastGpsPacket {
|
||||
static const int magic = 0x47; // 'G'
|
||||
static const int _payloadLength = 19;
|
||||
static const int _payloadLength = 16;
|
||||
// Store coordinates in microdegrees. This preserves sub-meter precision,
|
||||
// which comfortably satisfies the meter-accuracy requirement.
|
||||
static const double coordinateScale = 1e6;
|
||||
@@ -10,13 +25,15 @@ class FastGpsPacket {
|
||||
final String senderKey6;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final int timestampSeconds;
|
||||
|
||||
/// Ground speed in km/h (0..255), as carried in the beacon's trailing byte.
|
||||
final int speedKmh;
|
||||
|
||||
const FastGpsPacket({
|
||||
required this.senderKey6,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.timestampSeconds,
|
||||
this.speedKmh = 0,
|
||||
});
|
||||
|
||||
static bool isFastGpsBinary(Uint8List payload) =>
|
||||
@@ -32,7 +49,7 @@ class FastGpsPacket {
|
||||
final data = ByteData.sublistView(payload);
|
||||
final latitude = data.getInt32(7, Endian.little) / coordinateScale;
|
||||
final longitude = data.getInt32(11, Endian.little) / coordinateScale;
|
||||
final timestampSeconds = data.getUint32(15, Endian.little);
|
||||
final speedKmh = data.getUint8(15);
|
||||
|
||||
if (!_isValidCoordinate(latitude, longitude)) {
|
||||
return null;
|
||||
@@ -42,7 +59,7 @@ class FastGpsPacket {
|
||||
senderKey6: key6,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
timestampSeconds: timestampSeconds,
|
||||
speedKmh: speedKmh,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +72,7 @@ class FastGpsPacket {
|
||||
}
|
||||
data.setInt32(7, (latitude * coordinateScale).round(), Endian.little);
|
||||
data.setInt32(11, (longitude * coordinateScale).round(), Endian.little);
|
||||
data.setUint32(15, timestampSeconds, Endian.little);
|
||||
data.setUint8(15, speedKmh.clamp(0, 255));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -883,10 +883,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mgrs_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1456,10 +1456,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.11"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 2026.0420.1+55
|
||||
version: 2026.0613.2+57
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
|
||||
@@ -114,4 +114,59 @@ void main() {
|
||||
expect(contact.routeSummary, 'Flood/Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
group('ContactRouteCodec.toLegacyRawPath', () {
|
||||
test('zero-hop direct (0x40) → empty legacy path', () {
|
||||
final legacy = ContactRouteCodec.toLegacyRawPath(0x40, Uint8List(0));
|
||||
|
||||
expect(legacy.length, 0);
|
||||
expect(legacy.path, isEmpty);
|
||||
});
|
||||
|
||||
test('1-hop 2-byte hash (0x41) → first byte of the hash', () {
|
||||
final legacy = ContactRouteCodec.toLegacyRawPath(
|
||||
0x41,
|
||||
Uint8List.fromList([0x02, 0x62]),
|
||||
);
|
||||
|
||||
expect(legacy.length, 1);
|
||||
expect(legacy.path, [0x02]);
|
||||
});
|
||||
|
||||
test('2-hop 2-byte hash (0x42) → first byte of each hash', () {
|
||||
final legacy = ContactRouteCodec.toLegacyRawPath(
|
||||
0x42,
|
||||
Uint8List.fromList([0xAA, 0xBB, 0xCC, 0xDD]),
|
||||
);
|
||||
|
||||
expect(legacy.length, 2);
|
||||
expect(legacy.path, [0xAA, 0xCC]);
|
||||
});
|
||||
|
||||
test('signed 3-byte descriptor (-126 / 0x82) down-samples each hop', () {
|
||||
final outPath = Uint8List(64)
|
||||
..setRange(0, 6, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||
final legacy = ContactRouteCodec.toLegacyRawPath(-126, outPath);
|
||||
|
||||
expect(legacy.length, 2);
|
||||
expect(legacy.path, [0xAA, 0xDD]);
|
||||
});
|
||||
|
||||
test('legacy 1-byte hops (< 0x40) pass through trimmed', () {
|
||||
final legacy = ContactRouteCodec.toLegacyRawPath(
|
||||
0x03,
|
||||
Uint8List.fromList([0xAA, 0xBB, 0xCC, 0x00, 0x00]),
|
||||
);
|
||||
|
||||
expect(legacy.length, 3);
|
||||
expect(legacy.path, [0xAA, 0xBB, 0xCC]);
|
||||
});
|
||||
|
||||
test('unknown descriptor (0xFF) collapses to direct', () {
|
||||
final legacy = ContactRouteCodec.toLegacyRawPath(-1, Uint8List(0));
|
||||
|
||||
expect(legacy.length, 0);
|
||||
expect(legacy.path, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -762,10 +762,11 @@ void main() {
|
||||
senderKey6: '323334353637',
|
||||
latitude: 44.123456,
|
||||
longitude: 13.654321,
|
||||
timestampSeconds: 1700001234,
|
||||
speedKmh: 12,
|
||||
),
|
||||
);
|
||||
|
||||
final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final updated = provider.findContactByKey(publicKey)!;
|
||||
expect(updated.telemetry, isNotNull);
|
||||
expect(updated.telemetry!.gpsLocation, isNotNull);
|
||||
@@ -780,8 +781,9 @@ void main() {
|
||||
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
|
||||
expect(updated.advLat, equals((44.123456 * 1e6).round()));
|
||||
expect(updated.advLon, equals((13.654321 * 1e6).round()));
|
||||
expect(updated.lastAdvert, equals(1700001234));
|
||||
expect(updated.lastMod, equals(1700001234));
|
||||
// The beacon carries no timestamp; the receiver stamps its own RX time.
|
||||
expect(updated.lastAdvert, closeTo(nowSeconds, 5));
|
||||
expect(updated.lastMod, closeTo(nowSeconds, 5));
|
||||
expect(
|
||||
provider.estimatedLocationFor(updated.publicKeyHex),
|
||||
isNotNull,
|
||||
@@ -804,7 +806,7 @@ void main() {
|
||||
senderKey6: '010203040506',
|
||||
latitude: 10,
|
||||
longitude: 20,
|
||||
timestampSeconds: 99,
|
||||
speedKmh: 0,
|
||||
),
|
||||
);
|
||||
final after = provider.findContactByKey(publicKey)!;
|
||||
|
||||
@@ -6,22 +6,38 @@ import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
|
||||
|
||||
void main() {
|
||||
group('FastGpsPacket', () {
|
||||
test('encodes and parses a valid packet', () {
|
||||
test('encodes and parses a valid 16-byte packet', () {
|
||||
final packet = FastGpsPacket(
|
||||
senderKey6: 'aabbccddeeff',
|
||||
latitude: 46.0569,
|
||||
longitude: 14.5058,
|
||||
timestampSeconds: 1700000000,
|
||||
speedKmh: 37,
|
||||
);
|
||||
|
||||
final encoded = packet.encodeBinary();
|
||||
expect(encoded.length, equals(16));
|
||||
expect(encoded[0], equals(FastGpsPacket.magic));
|
||||
|
||||
final parsed = FastGpsPacket.tryParseBinary(encoded);
|
||||
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.senderKey6, equals('aabbccddeeff'));
|
||||
expect(parsed.latitude, closeTo(46.0569, 0.000001));
|
||||
expect(parsed.longitude, closeTo(14.5058, 0.000001));
|
||||
expect(parsed.timestampSeconds, equals(1700000000));
|
||||
expect(parsed.speedKmh, equals(37));
|
||||
});
|
||||
|
||||
test('clamps speed to a single unsigned byte', () {
|
||||
final packet = FastGpsPacket(
|
||||
senderKey6: '001122334455',
|
||||
latitude: 1.0,
|
||||
longitude: 2.0,
|
||||
speedKmh: 999,
|
||||
);
|
||||
|
||||
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.speedKmh, equals(255));
|
||||
});
|
||||
|
||||
test('supports negative coordinates', () {
|
||||
@@ -29,7 +45,7 @@ void main() {
|
||||
senderKey6: '001122334455',
|
||||
latitude: -33.8688,
|
||||
longitude: -151.2093,
|
||||
timestampSeconds: 42,
|
||||
speedKmh: 0,
|
||||
);
|
||||
|
||||
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
||||
@@ -39,20 +55,29 @@ void main() {
|
||||
});
|
||||
|
||||
test('rejects malformed payloads', () {
|
||||
// Too short.
|
||||
expect(
|
||||
FastGpsPacket.tryParseBinary(Uint8List.fromList([0x47, 0x01])),
|
||||
isNull,
|
||||
);
|
||||
// Wrong magic at the correct length.
|
||||
expect(
|
||||
FastGpsPacket.tryParseBinary(
|
||||
Uint8List.fromList(List<int>.filled(19, 0)..[0] = 0x48),
|
||||
Uint8List.fromList(List<int>.filled(16, 0)..[0] = 0x48),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
// Legacy 19-byte format is no longer accepted.
|
||||
expect(
|
||||
FastGpsPacket.tryParseBinary(
|
||||
Uint8List.fromList(List<int>.filled(19, 0)..[0] = 0x47),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects invalid coordinate ranges', () {
|
||||
final payload = Uint8List(19);
|
||||
final payload = Uint8List(16);
|
||||
payload[0] = FastGpsPacket.magic;
|
||||
payload.setRange(1, 7, [0, 1, 2, 3, 4, 5]);
|
||||
final data = ByteData.sublistView(payload);
|
||||
@@ -66,7 +91,7 @@ void main() {
|
||||
(14.5 * FastGpsPacket.coordinateScale).round(),
|
||||
Endian.little,
|
||||
);
|
||||
data.setUint32(15, 1, Endian.little);
|
||||
data.setUint8(15, 1);
|
||||
|
||||
expect(FastGpsPacket.tryParseBinary(payload), isNull);
|
||||
});
|
||||
@@ -78,7 +103,7 @@ void main() {
|
||||
senderKey6: 'aabbccddeeff',
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
timestampSeconds: 1700000000,
|
||||
speedKmh: 5,
|
||||
);
|
||||
|
||||
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
||||
|
||||
Reference in New Issue
Block a user