Merge branch 'main'

#Conflicts:
#	Meshtastic/AppIntents/RestartNodeIntent.swift
#	Meshtastic/Extensions/UserDefaults.swift
#	Meshtastic/MeshtasticApp.swift
This commit is contained in:
Garth Vander Houwen 2025-07-11 09:21:12 -07:00
commit 536975eff5
135 changed files with 14637 additions and 3569 deletions

21
.github/workflows/macos-dSYM.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: Upload dSYM Files
jobs:
build:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Generate/Download dSYM Files
uses: ./release.sh
- name: Upload dSYMs to Datadog
uses: DataDog/upload-dsyms-github-action@v1
with:
api_key: ${{ secrets.DATADOG_API_KEY }}
site: datadoghq.com
dsym_paths: |
path/to/dsyms/folder
path/to/zip/dsyms.zip

161
.github/workflows/sync_device_svgs.yml vendored Normal file
View file

@ -0,0 +1,161 @@
name: Sync Device SVGs
on:
schedule:
# Run nightly at 2 AM UTC
- cron: '0 2 * * *'
workflow_dispatch:
# Allow manual triggering
jobs:
sync-device-svgs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies
run: |
npm install -g svgo
- name: Download and process SVGs
run: |
#!/bin/bash
set -e
# Create temporary directory
mkdir -p temp_svgs
cd temp_svgs
# Clone web-flasher repo (shallow clone for speed)
git clone --depth 1 https://github.com/meshtastic/web-flasher.git
# Navigate to SVG directory
cd web-flasher/public/img/devices
# Create output directory
mkdir -p ../../../../processed_svgs
# Process each SVG file
for svg_file in *.svg; do
if [ -f "$svg_file" ]; then
# Get filename without extension
filename=$(basename "$svg_file" .svg)
# Optimize SVG
svgo "$svg_file" --output "../../../../processed_svgs/${filename}.svg"
echo "Processed: $filename"
fi
done
cd ../../../../
ls -la processed_svgs/
- name: Update Xcode Assets
run: |
#!/bin/bash
set -e
ASSETS_DIR="Meshtastic/Assets.xcassets"
# Ensure assets directory exists
mkdir -p "$ASSETS_DIR"
# Process each SVG
for svg_file in processed_svgs/*.svg; do
if [ -f "$svg_file" ]; then
# Get filename without extension
filename=$(basename "$svg_file" .svg)
# Create imageset directory
imageset_dir="${ASSETS_DIR}/${filename}.imageset"
mkdir -p "$imageset_dir"
# Copy SVG to imageset
cp "$svg_file" "${imageset_dir}/${filename}.svg"
# Create Contents.json for the imageset
cat > "${imageset_dir}/Contents.json" << EOF
{
"images" : [
{
"filename" : "${filename}.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
EOF
echo "Created imageset: ${filename}"
fi
done
- name: Check for changes
id: check_changes
run: |
if git diff --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
- name: Commit and push changes
if: steps.check_changes.outputs.has_changes == 'true'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add Meshtastic/Assets.xcassets/
git commit -m "🤖 Sync device SVGs from web-flasher repo
- Updated device images from meshtastic/web-flasher
- Automatically synced on $(date -u)
- Source: https://github.com/meshtastic/web-flasher/tree/main/public/img/devices"
git push
- name: Create PR (alternative to direct push)
if: steps.check_changes.outputs.has_changes == 'true' && false # Set to true if you prefer PRs
uses: peter-evans/create-pull-request@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "🤖 Sync device SVGs from web-flasher repo"
title: "Sync device SVGs from web-flasher"
body: |
This PR automatically syncs device SVG images from the [meshtastic/web-flasher](https://github.com/meshtastic/web-flasher) repository.
**Changes:**
- Updated device images from web-flasher repo
- Source: https://github.com/meshtastic/web-flasher/tree/main/public/img/devices
- Automatically generated on $(date -u)
The SVGs have been optimized and converted to Xcode asset format.
branch: sync-device-svgs
delete-branch: true
- name: Cleanup
if: always()
run: |
rm -rf temp_svgs processed_svgs
- name: Summary
run: |
if [ "${{ steps.check_changes.outputs.has_changes }}" == "true" ]; then
echo "✅ Device SVGs updated successfully"
else
echo " No changes detected - SVGs are up to date"
fi

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,12 @@
objects = {
/* Begin PBXBuildFile section */
102B5EAB2E172F41003D191E /* DatadogCore in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAA2E172F41003D191E /* DatadogCore */; };
102B5EAD2E172F41003D191E /* DatadogCrashReporting in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAC2E172F41003D191E /* DatadogCrashReporting */; };
102B5EAF2E172F41003D191E /* DatadogLogs in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAE2E172F41003D191E /* DatadogLogs */; };
102B5EB12E172F41003D191E /* DatadogRUM in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EB02E172F41003D191E /* DatadogRUM */; };
108FFECB2DD3F43C00BFAA81 /* ShareContactQRDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */; };
108FFECD2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */; };
231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */; };
231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; };
231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */; };
@ -28,6 +34,7 @@
2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; };
2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; };
2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */; };
237B46962DC8F1C100B22D99 /* RateLimitedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */; };
251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */; };
251926872C3BAE2200249DF5 /* NodeAlertsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */; };
2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */; };
@ -55,12 +62,14 @@
8D3F8A412D44C2A6009EAAA4 /* PowerMetricsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */; };
B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399E8A32B6F486400E4488E /* RetryButton.swift */; };
B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; };
BC47C2EF2CE0017D008245CA /* MessageNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC47C2EE2CE0017D008245CA /* MessageNodeIntent.swift */; };
BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */; };
BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */; };
BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613802C67290800485544 /* SendWaypointIntent.swift */; };
BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613822C672A2600485544 /* MessageChannelIntent.swift */; };
BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613842C68703800485544 /* NodePositionIntent.swift */; };
BCB613872C69A0FB00485544 /* AppIntentErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613862C69A0FB00485544 /* AppIntentErrors.swift */; };
BCD7448D2E0F2FAA00F265A2 /* ContactURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */; };
BCD93CBA2D9E11A2006C9214 /* DisconnectNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */; };
BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */; };
BCE2D3C32C7ADF42008E6199 /* ShutDownNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */; };
BCE2D3C52C7AE369008E6199 /* RestartNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */; };
@ -92,6 +101,11 @@
DD1BD0EB2C601795008C0C70 /* CLLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0EA2C601795008C0C70 /* CLLocation.swift */; };
DD1BD0EE2C603C91008C0C70 /* CustomFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0ED2C603C91008C0C70 /* CustomFormatters.swift */; };
DD1BD0F32C63C65E008C0C70 /* SecurityConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */; };
DD1BEF4A2E0292320090CE24 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF492E0292220090CE24 /* KeychainHelper.swift */; };
DD1BEF4C2E030D310090CE24 /* KeyBackupStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */; };
DD1BEF4E2E03916A0090CE24 /* ChannelsHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF4D2E0391620090CE24 /* ChannelsHelp.swift */; };
DD1BEF502E0528AA0090CE24 /* PersistantTips.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF4F2E0528A80090CE24 /* PersistantTips.swift */; };
DD1BEF522E08E9B80090CE24 /* ChannelLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF512E08E9AE0090CE24 /* ChannelLock.swift */; };
DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */; };
DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2160AE28C5552500C17253 /* MQTTConfig.swift */; };
DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */; };
@ -274,6 +288,8 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = "<group>"; };
108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = "<group>"; };
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = "<group>"; };
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = "<group>"; };
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = "<group>"; };
@ -295,6 +311,7 @@
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = "<group>"; };
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = "<group>"; };
2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultSeries.swift; sourceTree = "<group>"; };
237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedButton.swift; sourceTree = "<group>"; };
251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = "<group>"; };
251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = "<group>"; };
251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = "<group>"; };
@ -317,13 +334,15 @@
8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetricsLog.swift; sourceTree = "<group>"; };
B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = "<group>"; };
B3E905B02B71F7F300654D07 /* TextMessageField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageField.swift; sourceTree = "<group>"; };
BC47C2EE2CE0017D008245CA /* MessageNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageNodeIntent.swift; sourceTree = "<group>"; };
BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactIntent.swift; sourceTree = "<group>"; };
BC5EBA3B2D002A2000C442FF /* MessageNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageNodeIntent.swift; sourceTree = "<group>"; };
BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChannelSettingsIntent.swift; sourceTree = "<group>"; };
BCB613802C67290800485544 /* SendWaypointIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendWaypointIntent.swift; sourceTree = "<group>"; };
BCB613822C672A2600485544 /* MessageChannelIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageChannelIntent.swift; sourceTree = "<group>"; };
BCB613842C68703800485544 /* NodePositionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodePositionIntent.swift; sourceTree = "<group>"; };
BCB613862C69A0FB00485544 /* AppIntentErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentErrors.swift; sourceTree = "<group>"; };
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactURLHandler.swift; sourceTree = "<group>"; };
BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisconnectNodeIntent.swift; sourceTree = "<group>"; };
BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = "<group>"; };
BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShutDownNodeIntent.swift; sourceTree = "<group>"; };
BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartNodeIntent.swift; sourceTree = "<group>"; };
@ -343,6 +362,7 @@
DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyInfoEntityExtension.swift; sourceTree = "<group>"; };
DD007BAF2AA5981000F5FA12 /* NodeInfoEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityExtension.swift; sourceTree = "<group>"; };
DD05296F2B77F454008E44CD /* MeshtasticDataModelV 26.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 26.xcdatamodel"; sourceTree = "<group>"; };
DD0836AB2DE7C7CB00A3A973 /* MeshtasticDataModelV 52.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 52.xcdatamodel"; sourceTree = "<group>"; };
DD0BE30C2CB785D8000BA445 /* MeshtasticDataModelV 46.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 46.xcdatamodel"; sourceTree = "<group>"; };
DD0BE30F2CB9FDC4000BA445 /* DetectionSensorEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorEnums.swift; sourceTree = "<group>"; };
DD0E20FF2B892E1300F2D100 /* MeshtasticDataModelV 28.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 28.xcdatamodel"; sourceTree = "<group>"; };
@ -361,6 +381,12 @@
DD1BD0ED2C603C91008C0C70 /* CustomFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomFormatters.swift; sourceTree = "<group>"; };
DD1BD0F12C61D3AD008C0C70 /* MeshtasticDataModelV 42.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 42.xcdatamodel"; sourceTree = "<group>"; };
DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityConfig.swift; sourceTree = "<group>"; };
DD1BEF462DFF284C0090CE24 /* MeshtasticDataModelV 53.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 53.xcdatamodel"; sourceTree = "<group>"; };
DD1BEF492E0292220090CE24 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyBackupStatus.swift; sourceTree = "<group>"; };
DD1BEF4D2E0391620090CE24 /* ChannelsHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelsHelp.swift; sourceTree = "<group>"; };
DD1BEF4F2E0528A80090CE24 /* PersistantTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistantTips.swift; sourceTree = "<group>"; };
DD1BEF512E08E9AE0090CE24 /* ChannelLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelLock.swift; sourceTree = "<group>"; };
DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMessageList.swift; sourceTree = "<group>"; };
DD2160AE28C5552500C17253 /* MQTTConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTConfig.swift; sourceTree = "<group>"; };
DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralModel.swift; sourceTree = "<group>"; };
@ -410,6 +436,7 @@
DD6193742862F6E600E59241 /* ExternalNotificationConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalNotificationConfig.swift; sourceTree = "<group>"; };
DD6193762862F90F00E59241 /* CannedMessagesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CannedMessagesConfig.swift; sourceTree = "<group>"; };
DD6193782863875F00E59241 /* SerialConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConfig.swift; sourceTree = "<group>"; };
DD63CB4E2DD4FBEA00AFCAE2 /* MeshtasticDataModelV 51.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 51.xcdatamodel"; sourceTree = "<group>"; };
DD68BAE72C417A74004C01A0 /* MeshtasticDataModelV 40.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 40.xcdatamodel"; sourceTree = "<group>"; };
DD6D5A322CA1178300ED3032 /* TraceRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraceRoute.swift; sourceTree = "<group>"; };
DD6D5A342CA13BA600ED3032 /* MeshtasticDataModelV 45.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 45.xcdatamodel"; sourceTree = "<group>"; };
@ -571,7 +598,11 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
102B5EAD2E172F41003D191E /* DatadogCrashReporting in Frameworks */,
25A978BA2C13F8ED0003AAE7 /* MeshtasticProtobufs in Frameworks */,
102B5EAB2E172F41003D191E /* DatadogCore in Frameworks */,
102B5EAF2E172F41003D191E /* DatadogLogs in Frameworks */,
102B5EB12E172F41003D191E /* DatadogRUM in Frameworks */,
DD0D3D222A55CEB10066DB71 /* CocoaMQTT in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -672,6 +703,7 @@
BCB6137F2C6728E700485544 /* AppIntents */ = {
isa = PBXGroup;
children = (
BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */,
BC5EBA3B2D002A2000C442FF /* MessageNodeIntent.swift */,
BCB613802C67290800485544 /* SendWaypointIntent.swift */,
BCB613822C672A2600485544 /* MessageChannelIntent.swift */,
@ -682,7 +714,7 @@
BCE2D3C82C7C377F008E6199 /* FactoryResetNodeIntent.swift */,
BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */,
BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */,
BC47C2EE2CE0017D008245CA /* MessageNodeIntent.swift */,
BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */,
);
path = AppIntents;
sourceTree = "<group>";
@ -701,6 +733,7 @@
DD007BB12AA59B9A00F5FA12 /* CoreData */ = {
isa = PBXGroup;
children = (
108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */,
2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */,
DD58C5F12919AD3C00D5BEFB /* ChannelEntityExtension.swift */,
6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */,
@ -754,6 +787,7 @@
children = (
DDD5BB0E2C285F92007E03CA /* Logs */,
DD93800C2BA74CE3008BEC06 /* Channels */,
DD61937A2863876A00E59241 /* Config */,
DD97E96728EFE9A00056DDA4 /* About.swift */,
DDD5BB152C28B1E4007E03CA /* AppData.swift */,
DDD5BB082C285DDC007E03CA /* AppLog.swift */,
@ -767,7 +801,6 @@
DD3501882852FC3B000FC853 /* Settings.swift */,
DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */,
DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */,
DD61937A2863876A00E59241 /* Config */,
DD1B8F3F2B35E2F10022AABC /* GPSStatus.swift */,
);
path = Settings;
@ -788,6 +821,7 @@
DD61937A2863876A00E59241 /* Config */ = {
isa = PBXGroup;
children = (
DD61937B2863877A00E59241 /* Module */,
D93068DC2B81CA820066FBC8 /* ConfigHeader.swift */,
D93069072B81DF040066FBC8 /* SaveConfigButton.swift */,
DDB6ABD528AE742000384BA1 /* BluetoothConfig.swift */,
@ -798,7 +832,6 @@
DD2553582855B52700E55709 /* PositionConfig.swift */,
D93068DA2B81C85E0066FBC8 /* PowerConfig.swift */,
DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */,
DD61937B2863877A00E59241 /* Module */,
);
path = Config;
sourceTree = "<group>";
@ -832,6 +865,7 @@
DD6F65772C6EAB860053C113 /* Help */ = {
isa = PBXGroup;
children = (
DD1BEF4D2E0391620090CE24 /* ChannelsHelp.swift */,
DD6F65752C6EA5490053C113 /* AckErrors.swift */,
DD6F65782C6EADE60053C113 /* DirectMessagesHelp.swift */,
DD6F657A2C6EC2900053C113 /* LockLegend.swift */,
@ -850,6 +884,7 @@
DD7709392AA1ABA1007A8BF0 /* Tips */ = {
isa = PBXGroup;
children = (
DD1BEF4F2E0528A80090CE24 /* PersistantTips.swift */,
DD77093A2AA1ABB8007A8BF0 /* BluetoothTips.swift */,
DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */,
DDC1B8192AB5377B00C71E39 /* MessagesTips.swift */,
@ -870,6 +905,7 @@
DD8ED9C6289CE4A100B3B0AB /* Enums */ = {
isa = PBXGroup;
children = (
DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */,
DDA951592BC6624100CEA535 /* TelemetryWeather.swift */,
DDE0F7C4295F77B700B8AAB3 /* AppSettingsEnums.swift */,
DDB6ABD828B0A4BA00384BA1 /* BluetoothModes.swift */,
@ -1041,6 +1077,7 @@
DD3CC6BD28E4CD9800FA9159 /* BatteryGauge.swift */,
DD3CC24B2C498D6C001BD3A2 /* BatteryCompact.swift */,
DD457187293C7E63000C49FB /* BLESignalStrengthIndicator.swift */,
DD1BEF512E08E9AE0090CE24 /* ChannelLock.swift */,
DD47E3D526F17ED900029299 /* CircleText.swift */,
DDF924C926FBB953009FE055 /* ConnectedDevice.swift */,
DDD94A4F2845C8F5004A87A0 /* DateTimeText.swift */,
@ -1052,6 +1089,7 @@
DD5E523D298F5A7D00D21B61 /* Weather */,
DD6F65712C6AB8EC0053C113 /* SecureInput.swift */,
8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */,
237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */,
);
path = Helpers;
sourceTree = "<group>";
@ -1059,8 +1097,10 @@
DDC2E1A526CEB32B0042C5E4 /* Helpers */ = {
isa = PBXGroup;
children = (
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */,
DDD43FE12A78C86B0083A3E9 /* Mqtt */,
DDAF8C5226EB1DF10058C060 /* BLEManager.swift */,
DD1BEF492E0292220090CE24 /* KeychainHelper.swift */,
DDC2E1A626CEB3400042C5E4 /* LocationHelper.swift */,
DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */,
DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */,
@ -1109,6 +1149,7 @@
DDDB26402AABEF7B003AFCB7 /* Helpers */ = {
isa = PBXGroup;
children = (
108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */,
231B3F232D087C020069A07D /* Metrics Columns */,
DDAD49EB2AFAE82500B4425D /* Map */,
DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */,
@ -1214,6 +1255,10 @@
packageProductDependencies = (
DD0D3D212A55CEB10066DB71 /* CocoaMQTT */,
25A978B92C13F8ED0003AAE7 /* MeshtasticProtobufs */,
102B5EAA2E172F41003D191E /* DatadogCore */,
102B5EAC2E172F41003D191E /* DatadogCrashReporting */,
102B5EAE2E172F41003D191E /* DatadogLogs */,
102B5EB02E172F41003D191E /* DatadogRUM */,
);
productName = MeshtasticClient;
productReference = DDC2E15426CE248E0042C5E4 /* Meshtastic.app */;
@ -1277,12 +1322,14 @@
se,
sr,
it,
ja,
);
mainGroup = DDC2E14B26CE248E0042C5E4;
packageReferences = (
DD0D3D202A55CEB10066DB71 /* XCRemoteSwiftPackageReference "CocoaMQTT" */,
25A978B82C13F8ED0003AAE7 /* XCLocalSwiftPackageReference "MeshtasticProtobufs" */,
259792242C2F10B600AD1659 /* XCRemoteSwiftPackageReference "swift-protobuf" */,
102B5EA92E172F41003D191E /* XCRemoteSwiftPackageReference "dd-sdk-ios" */,
);
productRefGroup = DDC2E15526CE248E0042C5E4 /* Products */;
projectDirPath = "";
@ -1365,13 +1412,13 @@
25F26B1E2C2F610D00C9CD9D /* Logger.swift in Sources */,
259792252C2F114500AD1659 /* ChannelEntityExtension.swift in Sources */,
BCE2D3C52C7AE369008E6199 /* RestartNodeIntent.swift in Sources */,
DD1BEF522E08E9B80090CE24 /* ChannelLock.swift in Sources */,
259792262C2F114500AD1659 /* PositionEntityExtension.swift in Sources */,
259792272C2F114500AD1659 /* TraceRouteEntityExtension.swift in Sources */,
DDDB444829F8A9C900EE2349 /* String.swift in Sources */,
DDFFA7472B3A7F3C004730DB /* Bundle.swift in Sources */,
DD457188293C7E63000C49FB /* BLESignalStrengthIndicator.swift in Sources */,
DDA9515C2BC6631200CEA535 /* TelemetryEnums.swift in Sources */,
BC47C2EF2CE0017D008245CA /* MessageNodeIntent.swift in Sources */,
DDFEB3BB29900C1200EE7472 /* CurrentConditionsCompact.swift in Sources */,
DD836AE726F6B38600ABCC23 /* Connect.swift in Sources */,
D93069082B81DF040066FBC8 /* SaveConfigButton.swift in Sources */,
@ -1404,6 +1451,7 @@
DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */,
233E99C12D849D6000CC3A77 /* DistanceCompactWidget.swift in Sources */,
DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */,
108FFECD2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift in Sources */,
DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */,
DD15E4F32B8BA56E00654F61 /* PaxCounterConfig.swift in Sources */,
DDDB445229F8ACF900EE2349 /* Date.swift in Sources */,
@ -1414,15 +1462,19 @@
D9C9839D2B79CFD700BDBE6A /* TextMessageSize.swift in Sources */,
DDC94FCE29CF55310082EA6E /* RtttlConfig.swift in Sources */,
DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */,
DD1BEF4E2E03916A0090CE24 /* ChannelsHelp.swift in Sources */,
DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */,
BCD93CBA2D9E11A2006C9214 /* DisconnectNodeIntent.swift in Sources */,
DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */,
231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */,
25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */,
2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */,
DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */,
DD1BEF502E0528AA0090CE24 /* PersistantTips.swift in Sources */,
DDDB445429F8AD1600EE2349 /* Data.swift in Sources */,
DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */,
DDE5B4042B2279A700FCDD05 /* TraceRouteLog.swift in Sources */,
237B46962DC8F1C100B22D99 /* RateLimitedButton.swift in Sources */,
DD6193792863875F00E59241 /* SerialConfig.swift in Sources */,
DDDB263F2AABEE20003AFCB7 /* NodeList.swift in Sources */,
DDD5BB0B2C285E45007E03CA /* LogDetail.swift in Sources */,
@ -1434,6 +1486,7 @@
233E99C32D849D7A00CC3A77 /* WeightCompactWidget.swift in Sources */,
DD4A911E2708C65400501B7E /* AppSettings.swift in Sources */,
DD1BD0F32C63C65E008C0C70 /* SecurityConfig.swift in Sources */,
DD1BEF4C2E030D310090CE24 /* KeyBackupStatus.swift in Sources */,
DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */,
DD6F65722C6AB8EC0053C113 /* SecureInput.swift in Sources */,
DD13AA492AB73BF400BA0C98 /* PositionPopover.swift in Sources */,
@ -1462,6 +1515,7 @@
DDB6ABE628B1406100384BA1 /* LoraConfigEnums.swift in Sources */,
DDB8F4142A9EE5F000230ECE /* ChannelList.swift in Sources */,
DDD43FE32A78C8900083A3E9 /* MqttClientProxyManager.swift in Sources */,
BCD7448D2E0F2FAA00F265A2 /* ContactURLHandler.swift in Sources */,
DD007BB02AA5981000F5FA12 /* NodeInfoEntityExtension.swift in Sources */,
DDDB26422AABF655003AFCB7 /* NodeListItem.swift in Sources */,
DDDB444629F8A96500EE2349 /* Character.swift in Sources */,
@ -1494,6 +1548,7 @@
DDA1C48E28DB49D3009933EC /* ChannelRoles.swift in Sources */,
DDD5BB092C285DDC007E03CA /* AppLog.swift in Sources */,
DD8ED9C8289CE4B900B3B0AB /* RoutingError.swift in Sources */,
108FFECB2DD3F43C00BFAA81 /* ShareContactQRDialog.swift in Sources */,
233E99C52D84A0B600CC3A77 /* CompactWidget.swift in Sources */,
DDC1B81A2AB5377B00C71E39 /* MessagesTips.swift in Sources */,
DD964FC62975DBFD007C176F /* QueryCoreData.swift in Sources */,
@ -1551,6 +1606,7 @@
2344A2AB2D66974300170A77 /* ManagedAttributePropertyWrapper.swift in Sources */,
BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */,
D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */,
BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */,
DDA9515E2BC6F56F00CEA535 /* IndoorAirQuality.swift in Sources */,
DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */,
DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */,
@ -1565,6 +1621,7 @@
233E99C72D84A70900CC3A77 /* SoilCompactWidgets.swift in Sources */,
BCE2D3C92C7C377F008E6199 /* FactoryResetNodeIntent.swift in Sources */,
DD93800B2BA3F968008BEC06 /* NodeMapContent.swift in Sources */,
DD1BEF4A2E0292320090CE24 /* KeychainHelper.swift in Sources */,
DD41582A28585C32009B0E59 /* RangeTestConfig.swift in Sources */,
DD1925B728CDA5A400720036 /* CannedMessagesConfigEnums.swift in Sources */,
DDDB444429F8A8DD00EE2349 /* Float.swift in Sources */,
@ -1792,12 +1849,12 @@
INFOPLIST_FILE = Meshtastic/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Meshtastic;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.2;
MARKETING_VERSION = 2.6.11;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
@ -1825,12 +1882,12 @@
INFOPLIST_FILE = Meshtastic/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Meshtastic;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.2;
MARKETING_VERSION = 2.6.11;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
@ -1855,13 +1912,13 @@
INFOPLIST_FILE = Widgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Widgets;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.6.2;
MARKETING_VERSION = 2.6.11;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1887,13 +1944,13 @@
INFOPLIST_FILE = Widgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Widgets;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.6.2;
MARKETING_VERSION = 2.6.11;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1954,6 +2011,14 @@
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
102B5EA92E172F41003D191E /* XCRemoteSwiftPackageReference "dd-sdk-ios" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/DataDog/dd-sdk-ios.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.29.0;
};
};
259792242C2F10B600AD1659 /* XCRemoteSwiftPackageReference "swift-protobuf" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-protobuf.git";
@ -1973,6 +2038,26 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
102B5EAA2E172F41003D191E /* DatadogCore */ = {
isa = XCSwiftPackageProductDependency;
package = 102B5EA92E172F41003D191E /* XCRemoteSwiftPackageReference "dd-sdk-ios" */;
productName = DatadogCore;
};
102B5EAC2E172F41003D191E /* DatadogCrashReporting */ = {
isa = XCSwiftPackageProductDependency;
package = 102B5EA92E172F41003D191E /* XCRemoteSwiftPackageReference "dd-sdk-ios" */;
productName = DatadogCrashReporting;
};
102B5EAE2E172F41003D191E /* DatadogLogs */ = {
isa = XCSwiftPackageProductDependency;
package = 102B5EA92E172F41003D191E /* XCRemoteSwiftPackageReference "dd-sdk-ios" */;
productName = DatadogLogs;
};
102B5EB02E172F41003D191E /* DatadogRUM */ = {
isa = XCSwiftPackageProductDependency;
package = 102B5EA92E172F41003D191E /* XCRemoteSwiftPackageReference "dd-sdk-ios" */;
productName = DatadogRUM;
};
25A978B92C13F8ED0003AAE7 /* MeshtasticProtobufs */ = {
isa = XCSwiftPackageProductDependency;
productName = MeshtasticProtobufs;
@ -1992,6 +2077,9 @@
DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = {
isa = XCVersionGroup;
children = (
DD1BEF462DFF284C0090CE24 /* MeshtasticDataModelV 53.xcdatamodel */,
DD0836AB2DE7C7CB00A3A973 /* MeshtasticDataModelV 52.xcdatamodel */,
DD63CB4E2DD4FBEA00AFCAE2 /* MeshtasticDataModelV 51.xcdatamodel */,
233E99B32D84969500CC3A77 /* MeshtasticDataModelV 50.xcdatamodel */,
8D3F8A3D2D44B137009EAAA4 /* MeshtasticDataModelV 49.xcdatamodel */,
DDA28B1B2D32C89200EF726F /* MeshtasticDataModelV 48.xcdatamodel */,
@ -2043,7 +2131,7 @@
DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */,
DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */,
);
currentVersion = 233E99B32D84969500CC3A77 /* MeshtasticDataModelV 50.xcdatamodel */;
currentVersion = DD1BEF462DFF284C0090CE24 /* MeshtasticDataModelV 53.xcdatamodel */;
name = Meshtastic.xcdatamodeld;
path = Meshtastic/Meshtastic.xcdatamodeld;
sourceTree = "<group>";

View file

@ -0,0 +1,48 @@
//
// AddContactIntent.swift
// Meshtastic
//
// Created by Benjamin Faershtein on 5/13/25.
//
import AppIntents
import MeshtasticProtobufs
struct AddContactIntent: AppIntent {
static var title: LocalizedStringResource = "Add Contact"
static var description: IntentDescription = "Takes a Meshtastic contact URL and saves it to the nodes database"
@Parameter(title: "Contact URL", description: "The URL for the node to add")
var contactUrl: URL
// Define the function that performs the main logic
func perform() async throws -> some IntentResult {
// Ensure the BLE Manager is connected
if !BLEManager.shared.isConnected {
throw AppIntentErrors.AppIntentError.notConnected
}
if contactUrl.absoluteString.lowercased().contains("meshtastic.org/v/#") {
let components = self.contactUrl.absoluteString.components(separatedBy: "#")
// Extract contact information from the URL
if let contactData = components.last {
let decodedString = contactData.base64urlToBase64()
if let decodedData = Data(base64Encoded: decodedString) {
do {
let success = BLEManager.shared.addContactFromURL(base64UrlString: contactData)
if !success {
throw AppIntentErrors.AppIntentError.message("Failed to add contact")
}
} catch {
throw AppIntentErrors.AppIntentError.message("Failed to parse contact data: \(error.localizedDescription)")
}
}
}
// Return a success result
return .result()
} else {
throw AppIntentErrors.AppIntentError.message("The URL is not a valid Meshtastic contact link")
}
}
}

View file

@ -0,0 +1,30 @@
//
// DisconnectNodeIntent.swift
// Meshtastic
//
// Created by Benjamin Faershtein on 4/2/25.
//
import Foundation
import AppIntents
struct DisconnectNodeIntent: AppIntent {
static var title: LocalizedStringResource = "Disconnect Node"
static var description: IntentDescription = "Disconnect the currently connected node"
func perform() async throws -> some IntentResult {
if !BLEManager.shared.isConnected {
throw AppIntentErrors.AppIntentError.notConnected
}
if let connectedPeripheral = BLEManager.shared.connectedPeripheral,
connectedPeripheral.peripheral.state == .connected {
BLEManager.shared.disconnectPeripheral(reconnect: false)
} else {
throw AppIntentErrors.AppIntentError.message("Error disconnecting node")
}
return .result()
}
}

View file

@ -11,11 +11,19 @@ import AppIntents
struct FactoryResetNodeIntent: AppIntent {
static var title: LocalizedStringResource = "Factory Reset"
static var description: IntentDescription = "Perform a factory reset on the node you are connected to"
@Parameter(title: "Hard Reset", description: "In addition to Config, Keys and BLE bonds will be wiped", default: false)
var hardReset: Bool
@Parameter(title: "Provide Confirmation", description: "Show a confirmation dialog before performing the factory reset", default: true)
var provideConfirmation: Bool
func perform() async throws -> some IntentResult {
// Request user confirmation before performing the factory reset
try await requestConfirmation(result: .result(dialog: "Are you sure you want to factory reset the node?"), confirmationActionName: ConfirmationActionName
.custom(acceptLabel: "Factory Reset", acceptAlternatives: [], denyLabel: "Cancel", denyAlternatives: [], destructive: true))
if provideConfirmation {
try await requestConfirmation(result: .result(dialog: "Are you sure you want to factory reset the node?"), confirmationActionName: ConfirmationActionName
.custom(acceptLabel: "Factory Reset", acceptAlternatives: [], denyLabel: "Cancel", denyAlternatives: [], destructive: true))
}
// Ensure the node is connected
if !BLEManager.shared.isConnected {
@ -29,7 +37,7 @@ struct FactoryResetNodeIntent: AppIntent {
let toUser = connectedNode.user {
// Attempt to send a factory reset command, throw an error if it fails
if !BLEManager.shared.sendFactoryReset(fromUser: fromUser, toUser: toUser) {
if !BLEManager.shared.sendFactoryReset(fromUser: fromUser, toUser: toUser, resetDevice: hardReset) {
throw AppIntentErrors.AppIntentError.message("Failed to perform factory reset")
}
} else {

View file

@ -15,7 +15,6 @@ struct RestartNodeIntent: AppIntent {
func perform() async throws -> some IntentResult {
try await requestConfirmation(result: .result(dialog: "Reboot Node?"))
if !BLEManager.shared.isConnected {
throw AppIntentErrors.AppIntentError.notConnected
@ -24,11 +23,10 @@ struct RestartNodeIntent: AppIntent {
if let connectedPeripheralNum = BLEManager.shared.connectedPeripheral?.num,
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext),
let fromUser = connectedNode.user,
let toUser = connectedNode.user,
let adminIndex = connectedNode.myInfo?.adminIndex {
let toUser = connectedNode.user {
// Attempt to send shutdown, throw an error if it fails
if !BLEManager.shared.sendReboot(fromUser: fromUser, toUser: toUser, adminIndex: adminIndex) {
if !BLEManager.shared.sendReboot(fromUser: fromUser, toUser: toUser) {
throw AppIntentErrors.AppIntentError.message("Failed to restart")
}
} else {

View file

@ -11,6 +11,8 @@ import AppIntents
import MeshtasticProtobufs
struct SendWaypointIntent: AppIntent {
var defaultDate = Date.now.addingTimeInterval(60 * 480)
static var title = LocalizedStringResource("Send a Waypoint")
@ -23,13 +25,24 @@ struct SendWaypointIntent: AppIntent {
@Parameter(title: "Emoji", default: "📍")
var emojiParameter: String?
@Parameter(title: "Location")
var locationParameter: CLPlacemark
// Replace CLPlacemark with latitude and longitude parameters
@Parameter(title: "Latitude", description: "Latitude in degrees (e.g., 37.7749)")
var latitudeParameter: Double
@Parameter(title: "Longitude", description: "Longitude in degrees (e.g., -122.4194)")
var longitudeParameter: Double
@Parameter(title: "Locked", default: false)
var isLocked: Bool
@Parameter(title: "Expiration")
var expiration: Date?
func perform() async throws -> some IntentResult {
if !BLEManager.shared.isConnected {
throw AppIntentErrors.AppIntentError.notConnected
}
// Provide default values if parameters are nil
let name = nameParameter ?? "Dropped Pin"
let description = descriptionParameter ?? ""
@ -50,24 +63,39 @@ struct SendWaypointIntent: AppIntent {
throw $emojiParameter.needsValueError("Must be a single emoji")
}
// Validate latitude and longitude
guard abs(latitudeParameter) <= 90 else {
throw $latitudeParameter.needsValueError("Latitude must be between -90 and 90 degrees")
}
guard abs(longitudeParameter) <= 180 else {
throw $longitudeParameter.needsValueError("Longitude must be between -180 and 180 degrees")
}
var newWaypoint = Waypoint()
if let latitude = locationParameter.location?.coordinate.latitude {
newWaypoint.latitudeI = Int32(latitude * 10_000_000)
}
if let longitude = locationParameter.location?.coordinate.longitude {
newWaypoint.longitudeI = Int32(longitude * 10_000_000)
}
// Set latitude and longitude directly
newWaypoint.latitudeI = Int32(latitudeParameter * 10_000_000)
newWaypoint.longitudeI = Int32(longitudeParameter * 10_000_000)
newWaypoint.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
// Unicode scalar value for the icon emoji string
let unicodeScalers = emoji.unicodeScalars
// First element as an UInt32
let unicode = unicodeScalers[unicodeScalers.startIndex].value
newWaypoint.icon = unicode
newWaypoint.name = name
newWaypoint.description_p = description
if let expirationDate = expiration {
newWaypoint.expire = UInt32(expirationDate.timeIntervalSince1970)
}
if isLocked {
if let connectedPeripheral = BLEManager.shared.connectedPeripheral {
newWaypoint.lockedTo = UInt32(connectedPeripheral.num)
} else {
throw AppIntentErrors.AppIntentError.notConnected
}
}
if !BLEManager.shared.sendWaypoint(waypoint: newWaypoint) {
throw AppIntentErrors.AppIntentError.message("Failed to Send Waypoint")
}
@ -75,11 +103,9 @@ struct SendWaypointIntent: AppIntent {
}
private func isValidSingleEmoji(_ emoji: String) -> Bool {
// This regex pattern is for matching a single emoji
let emojiPattern = "^([\\p{So}\\p{Cn}])$"
let regex = try? NSRegularExpression(pattern: emojiPattern, options: [])
let matches = regex?.matches(in: emoji, options: [], range: NSRange(location: 0, length: emoji.utf16.count))
return matches?.count == 1
}
}

View file

@ -32,5 +32,12 @@ struct ShortcutsProvider: AppShortcutsProvider {
"Send a \(.applicationName) group message"],
shortTitle: "Group Message",
systemImageName: "message")
AppShortcut(intent: DisconnectNodeIntent(),
phrases: ["Disconnect \(.applicationName) node",
"Disconnect my \(.applicationName) node",
"Disconnect from \(.applicationName)",
"Disconnect \(.applicationName)"],
shortTitle: "Disconnect",
systemImageName: "antenna.radiowaves.left.and.right.slash")
}
}

View file

@ -24,11 +24,10 @@ struct ShutDownNodeIntent: AppIntent {
if let connectedPeripheralNum = BLEManager.shared.connectedPeripheral?.num,
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext),
let fromUser = connectedNode.user,
let toUser = connectedNode.user,
let adminIndex = connectedNode.myInfo?.adminIndex {
let toUser = connectedNode.user {
// Attempt to send shutdown, throw an error if it fails
if !BLEManager.shared.sendShutdown(fromUser: fromUser, toUser: toUser, adminIndex: adminIndex) {
if !BLEManager.shared.sendShutdown(fromUser: fromUser, toUser: toUser) {
throw AppIntentErrors.AppIntentError.message("Failed to shut down")
}
} else {

View file

@ -0,0 +1,27 @@
import Foundation
import AppIntents
struct TracerouteIntent: AppIntent {
static var title: LocalizedStringResource = "Send a Traceroute"
static var description: IntentDescription = "Send a traceroute request to a certain Meshtastic node"
@Parameter(title: "Node Number")
var nodeNumber: Int
static var parameterSummary: some ParameterSummary {
Summary("Send traceroute to \(\.$nodeNumber)")
}
func perform() async throws -> some IntentResult {
if !BLEManager.shared.isConnected {
throw AppIntentErrors.AppIntentError.notConnected
}
if !BLEManager.shared.sendTraceRouteRequest(destNum: Int64(nodeNumber), wantResponse: true) {
throw AppIntentErrors.AppIntentError.message("Failed to send traceroute request")
}
return .result()
}
}

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "heltec_mesh_pocket.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,196 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="379.51 218.53 478.45 660.45"
version="1.1"
id="svg72"
sodipodi:docname="heltec_mesh_pocket.svg"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview72"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.78391112"
inkscape:cx="4.4647919"
inkscape:cy="146.06248"
inkscape:window-width="1472"
inkscape:window-height="890"
inkscape:window-x="0"
inkscape:window-y="38"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_2" />
<defs
id="defs1">
<style
id="style1">.cls-1{fill:#d5bd0a;}.cls-2{fill:#eceded;}.cls-3{fill:#e3e1e0;}.cls-4{fill:#1d1d1b;}.cls-12,.cls-5,.cls-7{fill:none;}.cls-5,.cls-7{stroke:#1d1d1b;stroke-miterlimit:10;}.cls-5{stroke-width:0.87px;}.cls-6{fill:#fff;}.cls-7{stroke-width:3.49px;}.cls-8{fill:#2b293d;}.cls-9{fill:#2ea358;}.cls-10{fill:#efefef;}.cls-11{fill:#acd087;}.cls-12{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.31px;}.cls-13{fill:#2590d0;}.cls-14{fill:#4e4d4e;}</style>
</defs>
<g
id="Layer_2"
data-name="Layer 2">
<path
class="cls-1"
d="M784.42,219.19H419.9a39.73,39.73,0,0,0-39.74,39.73V838.6a39.74,39.74,0,0,0,39.74,39.73H784.42a39.73,39.73,0,0,0,39.73-39.73V258.92A39.73,39.73,0,0,0,784.42,219.19Zm31,619.41a31,31,0,0,1-31,31H419.9a31,31,0,0,1-31-31V258.92a31,31,0,0,1,31-31H784.42a31,31,0,0,1,31,31Z"
id="path1" />
<rect
class="cls-2"
x="388.9"
y="227.92"
width="426.51"
height="641.67"
rx="31"
id="rect1"
style="fill:#fcfcfc;fill-opacity:1" />
<path
class="cls-1"
d="M857.3,302.32V633.56a7.53,7.53,0,0,1-1.75,4.84l-31.4,37.77V260a67.74,67.74,0,0,1,32.06,36.41A16.78,16.78,0,0,1,857.3,302.32Z"
id="path2" />
<rect
class="cls-3"
x="439.49"
y="398.62"
width="324.87"
height="163.57"
id="rect2"
style="fill:#bfbfbf;fill-opacity:1" />
<rect
class="cls-8"
x="513.04"
y="724.36"
width="166.81"
height="33.44"
rx="3.87"
id="rect48" />
<path
class="cls-9"
d="M558.92,724.36V757.8h-42a3.87,3.87,0,0,1-3.87-3.87V728.24a3.88,3.88,0,0,1,3.87-3.88Z"
id="path48" />
<rect
class="cls-10"
x="514.95"
y="740.28"
width="20.46"
height="2.8"
transform="translate(-383.39 761.4) rotate(-55.94)"
id="rect49" />
<path
class="cls-10"
d="M551.65,750.93,541.54,736l-10.12,14.93-2.31-1.56L540,733.29a1.8,1.8,0,0,1,1.54-.82h0a1.85,1.85,0,0,1,1.57.87l10.85,16Zm-10.88-16.07s0,0,0,0Zm1.57,0,0,0Z"
id="path49" />
<path
class="cls-10"
d="M566.72,731.61a31.88,31.88,0,0,1,5.15-.39c2.66,0,4.6.6,5.84,1.68a5.43,5.43,0,0,1,1.82,4.25,5.93,5.93,0,0,1-1.61,4.35,8.72,8.72,0,0,1-6.36,2.23,9.52,9.52,0,0,1-2.16-.18v8.14h-2.68Zm2.68,9.8a9.32,9.32,0,0,0,2.23.21c3.24,0,5.21-1.54,5.21-4.34s-1.94-4-4.9-4a11.68,11.68,0,0,0-2.54.21Z"
id="path50" />
<path
class="cls-10"
d="M595.8,744.27c0,5.4-3.83,7.75-7.44,7.75-4,0-7.16-2.89-7.16-7.51,0-4.88,3.27-7.75,7.41-7.75S595.8,739.81,595.8,744.27Zm-11.85.15c0,3.2,1.88,5.61,4.54,5.61s4.53-2.38,4.53-5.67c0-2.47-1.26-5.61-4.47-5.61S584,741.65,584,744.42Z"
id="path51" />
<path
class="cls-10"
d="M600.1,737.1l2,7.41c.43,1.63.84,3.14,1.11,4.65h.1c.34-1.48.83-3,1.32-4.62L607,737.1h2.29l2.31,7.29c.56,1.75,1,3.29,1.33,4.77h.09a44.65,44.65,0,0,1,1.14-4.74l2.13-7.32H619l-4.82,14.59h-2.47l-2.28-7a48.12,48.12,0,0,1-1.33-4.79h-.06a40.69,40.69,0,0,1-1.36,4.82l-2.41,6.94h-2.47l-4.5-14.59Z"
id="path52" />
<path
class="cls-10"
d="M623,744.87c.06,3.59,2.41,5.07,5.12,5.07a10.08,10.08,0,0,0,4.14-.75l.46,1.9a12.41,12.41,0,0,1-5,.9c-4.6,0-7.35-3-7.35-7.36s2.66-7.87,7-7.87c4.88,0,6.18,4.19,6.18,6.88a9.56,9.56,0,0,1-.1,1.23Zm8-1.89c0-1.69-.71-4.32-3.76-4.32-2.75,0-4,2.48-4.17,4.32Z"
id="path53" />
<path
class="cls-10"
d="M636.9,741.65c0-1.72,0-3.2-.12-4.55h2.38l.09,2.86h.12a4.48,4.48,0,0,1,4.14-3.2,3,3,0,0,1,.77.09v2.51a4.19,4.19,0,0,0-.93-.09,3.77,3.77,0,0,0-3.64,3.4,7.56,7.56,0,0,0-.12,1.24v7.78H636.9Z"
id="path54" />
<path
class="cls-10"
d="M648.2,744.87c.07,3.59,2.41,5.07,5.13,5.07a10.11,10.11,0,0,0,4.14-.75l.46,1.9a12.44,12.44,0,0,1-5,.9c-4.6,0-7.35-3-7.35-7.36s2.66-7.87,7-7.87c4.88,0,6.17,4.19,6.17,6.88a10,10,0,0,1-.09,1.23Zm8-1.89c0-1.69-.71-4.32-3.77-4.32-2.75,0-3.95,2.48-4.17,4.32Z"
id="path55" />
<path
class="cls-10"
d="M675.07,730.28v17.64c0,1.3,0,2.77.12,3.77h-2.44l-.12-2.53h-.07a5.55,5.55,0,0,1-5.09,2.86c-3.61,0-6.39-3-6.39-7.42,0-4.85,3.06-7.84,6.7-7.84a5.05,5.05,0,0,1,4.51,2.23h.06v-8.71ZM672.35,743a4.21,4.21,0,0,0-.13-1.11,4,4,0,0,0-3.91-3.08c-2.81,0-4.48,2.42-4.48,5.64,0,3,1.48,5.4,4.41,5.4a4.06,4.06,0,0,0,4-3.17,4.15,4.15,0,0,0,.13-1.14Z"
id="path56" />
<rect
class="cls-11"
x="561.8"
y="768.06"
width="65.15"
height="6.66"
rx="3.33"
id="rect56" />
<path
class="cls-12"
d="M784.42,219.19H419.9a39.73,39.73,0,0,0-39.74,39.73V838.6a39.74,39.74,0,0,0,39.74,39.73H784.42a39.73,39.73,0,0,0,39.73-39.73V258.92A39.73,39.73,0,0,0,784.42,219.19Zm31,619.41a31,31,0,0,1-31,31H419.9a31,31,0,0,1-31-31V258.92a31,31,0,0,1,31-31H784.42a31,31,0,0,1,31,31Z"
id="path57" />
<path
class="cls-12"
d="M824.15,260a67.74,67.74,0,0,1,32.06,36.41,16.78,16.78,0,0,1,1.09,5.87V633.56a7.53,7.53,0,0,1-1.75,4.84l-31.4,37.77"
id="path58" />
<path
class="cls-13"
d="M714.73,253.2s0,0,0,0l-.24,1-7.2,29a4.47,4.47,0,0,0,.7,3.69l.06.07a15.25,15.25,0,0,1-1.59-.42,9.75,9.75,0,0,1-3.94-2.24,6.35,6.35,0,0,1-1.63-6.06l.67-2.7L705,261.64h-4.77l-1.67,6.73c-.52,2.1-.6,3,.25,5a1.18,1.18,0,0,1,0,.25l-2.48-1.32a5.56,5.56,0,0,1-2.95-6.41l4.11-16.56a.09.09,0,0,0,0,0,4.73,4.73,0,0,0-.86-4.11l1.69.75.76.34a5.79,5.79,0,0,1,3.27,6.67l-1,4h4.76l2.07-8.35a4.52,4.52,0,0,0,0-2,6.32,6.32,0,0,0-1.17-2.68,13,13,0,0,1,2.1.71C711.81,245.69,715.73,248.21,714.73,253.2Z"
id="path59" />
<path
class="cls-14"
d="M724.83,258.46l-.59,2.36h5l-1.12,4.54h-5l-.76,3.06h5L726.25,273h-9.88l1.13-4.54.76-3.06,1.12-4.54.59-2.36.71-2.86a2.23,2.23,0,0,1,1.27-1.5h0a2.22,2.22,0,0,1,.87-.18H731l-1.13,4.54Z"
id="path60" />
<path
class="cls-14"
d="M765.52,258.46l-.59,2.36h5l-1.13,4.54h-5l-.76,3.06h5L766.94,273h-9.88l1.13-4.54.76-3.06,1.13-4.54.58-2.36.71-2.86a2.22,2.22,0,0,1,1.28-1.5h0a2.15,2.15,0,0,1,.87-.18h8.14l-1.13,4.54Z"
id="path61" />
<polygon
class="cls-14"
points="758.02 253.92 757.07 257.81 753.56 257.81 753.39 258.46 752.81 260.82 749.8 272.96 744.93 272.96 746.07 268.42 746.82 265.36 747.95 260.82 748.53 258.46 748.7 257.81 745.1 257.81 745.91 254.53 746.06 253.92 758.02 253.92"
id="polygon61" />
<polygon
class="cls-14"
points="741.13 268.42 740 272.96 730.13 272.96 731.26 268.42 732.01 265.36 733.14 260.82 733.73 258.46 734.7 254.53 734.85 253.92 739.71 253.92 738.59 258.46 738 260.82 736.12 268.42 741.13 268.42"
id="polygon62" />
<path
class="cls-14"
d="M788.73,255l-1.5,6.26a3.48,3.48,0,0,0-1.3-2.36c-1.86-1.4-4.88-.52-6.78,2s-1.92,5.66-.07,7.05,4.88.52,6.77-2c.15-.2.28-.4.41-.61l-1.73,7.24a7.65,7.65,0,0,1-6.77.91c-4.31-1.62-6.17-7.27-4.16-12.64C775.55,255.69,781.94,250.72,788.73,255Z"
id="path62" />
<path
class="cls-14"
d="M718.84,282.11h-2.13l-.55,1h-1.92l3.81-6.16h2.05l.76,6.16h-2Zm-.06-1.33-.12-2.22-1.21,2.22Z"
id="path63" />
<path
class="cls-14"
d="M726.65,277h1.88l-.91,3.67a3.57,3.57,0,0,1-.43,1,3.2,3.2,0,0,1-1.61,1.36,4.44,4.44,0,0,1-1.37.2,8.21,8.21,0,0,1-1-.06,2,2,0,0,1-.82-.25,1.53,1.53,0,0,1-.51-.53,1.47,1.47,0,0,1-.22-.71,3.41,3.41,0,0,1,.08-1l.91-3.67h1.87l-.93,3.75a1,1,0,0,0,.08.79.81.81,0,0,0,.7.29,1.32,1.32,0,0,0,.83-.28,1.4,1.4,0,0,0,.47-.8Z"
id="path64" />
<path
class="cls-14"
d="M729.34,277h5.72l-.38,1.52h-1.92l-1.15,4.64h-1.88l1.15-4.64H729Z"
id="path65" />
<path
class="cls-14"
d="M734.62,280.05A4.25,4.25,0,0,1,736,277.7a3.87,3.87,0,0,1,2.52-.84,2.49,2.49,0,0,1,2.13.83,2.69,2.69,0,0,1,.25,2.31,4.83,4.83,0,0,1-.8,1.77,3.77,3.77,0,0,1-1.3,1.08,3.93,3.93,0,0,1-1.79.38,3.32,3.32,0,0,1-1.62-.33,1.81,1.81,0,0,1-.83-1A3.25,3.25,0,0,1,734.62,280.05Zm1.87,0a1.91,1.91,0,0,0,0,1.34.89.89,0,0,0,.83.41,1.46,1.46,0,0,0,1-.4,3,3,0,0,0,.69-1.43,1.78,1.78,0,0,0,0-1.28.94.94,0,0,0-.85-.4,1.48,1.48,0,0,0-1,.41A2.67,2.67,0,0,0,736.49,280.06Z"
id="path66" />
<path
class="cls-14"
d="M742.67,277h2.47l0,3.75L747,277h2.47L748,283.13h-1.54l1.17-4.7-2.35,4.7h-1.39l0-4.7-1.17,4.7h-1.54Z"
id="path67" />
<path
class="cls-14"
d="M753.17,282.11H751l-.55,1h-1.92l3.82-6.16h2.05l.75,6.16h-2Zm-.06-1.33-.12-2.22-1.21,2.22Z"
id="path68" />
<path
class="cls-14"
d="M756.31,277H762l-.38,1.52h-1.92l-1.15,4.64h-1.88l1.16-4.64h-1.92Z"
id="path69" />
<path
class="cls-14"
d="M762.94,277h1.89l-1.53,6.16h-1.89Z"
id="path70" />
<path
class="cls-14"
d="M765.17,280.05a4.21,4.21,0,0,1,1.42-2.35,3.86,3.86,0,0,1,2.51-.84,2.5,2.5,0,0,1,2.14.83,2.69,2.69,0,0,1,.25,2.31,4.83,4.83,0,0,1-.8,1.77,3.8,3.8,0,0,1-1.31,1.08,3.89,3.89,0,0,1-1.78.38,3.32,3.32,0,0,1-1.62-.33,1.77,1.77,0,0,1-.83-1A3.23,3.23,0,0,1,765.17,280.05Zm1.88,0a1.91,1.91,0,0,0,0,1.34.89.89,0,0,0,.83.41,1.47,1.47,0,0,0,1-.4,3,3,0,0,0,.68-1.43,1.68,1.68,0,0,0,0-1.28.91.91,0,0,0-.84-.4,1.47,1.47,0,0,0-1,.41A2.67,2.67,0,0,0,767.05,280.06Z"
id="path71" />
<path
class="cls-14"
d="M773.26,277H775l1.44,3.41.85-3.41h1.77l-1.53,6.16h-1.77l-1.44-3.39-.84,3.39h-1.77Z"
id="path72" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "seeed_solar.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 98 KiB

View file

@ -0,0 +1,12 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"symbols" : [
{
"filename" : "progress.ring.dashed.svg",
"idiom" : "universal"
}
]
}

View file

@ -0,0 +1,169 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 3300 2200">
<!--glyph: "", point size: 100.0, font version: "20.0d10e1", template writer version: "138.0.0"-->
<style>.defaults {-sfsymbols-wiggle-style:clockwise;-sfsymbols-rotates-clockwise:true}
.monochrome-0 {-sfsymbols-variable-threshold:0.93;-sfsymbols-motion-group:12;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.monochrome-1 {-sfsymbols-variable-threshold:0.86;-sfsymbols-motion-group:11;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.monochrome-2 {-sfsymbols-variable-threshold:0.78;-sfsymbols-motion-group:10;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.monochrome-3 {-sfsymbols-variable-threshold:0.7;-sfsymbols-motion-group:9;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.monochrome-4 {-sfsymbols-variable-threshold:0.63;-sfsymbols-motion-group:8;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.monochrome-5 {-sfsymbols-variable-threshold:0.55;-sfsymbols-motion-group:7;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.monochrome-6 {-sfsymbols-variable-threshold:0.47;-sfsymbols-motion-group:6;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.monochrome-7 {-sfsymbols-variable-threshold:0.39;-sfsymbols-motion-group:5;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.monochrome-8 {-sfsymbols-variable-threshold:0.32;-sfsymbols-motion-group:4;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.monochrome-9 {-sfsymbols-variable-threshold:0.24;-sfsymbols-motion-group:3;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.monochrome-10 {-sfsymbols-variable-threshold:0.16;-sfsymbols-motion-group:2;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.monochrome-11 {-sfsymbols-variable-threshold:0.09;-sfsymbols-motion-group:1;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-0:tintColor {-sfsymbols-variable-threshold:0.93;-sfsymbols-motion-group:12;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-1:tintColor {-sfsymbols-variable-threshold:0.86;-sfsymbols-motion-group:11;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-2:tintColor {-sfsymbols-variable-threshold:0.78;-sfsymbols-motion-group:10;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-3:tintColor {-sfsymbols-clear-behind:true;-sfsymbols-variable-threshold:0.7;-sfsymbols-motion-group:9;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-4:tintColor {-sfsymbols-variable-threshold:0.63;-sfsymbols-motion-group:8;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-5:tintColor {-sfsymbols-variable-threshold:0.55;-sfsymbols-motion-group:7;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-6:tintColor {-sfsymbols-variable-threshold:0.47;-sfsymbols-motion-group:6;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-7:tintColor {-sfsymbols-variable-threshold:0.39;-sfsymbols-motion-group:5;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-8:tintColor {-sfsymbols-variable-threshold:0.32;-sfsymbols-motion-group:4;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-9:tintColor {-sfsymbols-variable-threshold:0.24;-sfsymbols-motion-group:3;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-10:tintColor {-sfsymbols-variable-threshold:0.16;-sfsymbols-motion-group:2;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.multicolor-11:tintColor {-sfsymbols-variable-threshold:0.09;-sfsymbols-motion-group:1;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-0:primary {-sfsymbols-variable-threshold:0.93;-sfsymbols-motion-group:12;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-1:primary {-sfsymbols-variable-threshold:0.86;-sfsymbols-motion-group:11;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-2:primary {-sfsymbols-variable-threshold:0.78;-sfsymbols-motion-group:10;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-3:primary {-sfsymbols-clear-behind:true;-sfsymbols-variable-threshold:0.7;-sfsymbols-motion-group:9;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-4:secondary {-sfsymbols-variable-threshold:0.63;-sfsymbols-motion-group:8;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-5:tertiary {-sfsymbols-variable-threshold:0.55;-sfsymbols-motion-group:7;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-6:primary {-sfsymbols-variable-threshold:0.47;-sfsymbols-motion-group:6;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-7:primary {-sfsymbols-variable-threshold:0.39;-sfsymbols-motion-group:5;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-8:primary {-sfsymbols-variable-threshold:0.32;-sfsymbols-motion-group:4;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-9:primary {-sfsymbols-variable-threshold:0.24;-sfsymbols-motion-group:3;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-10:primary {-sfsymbols-variable-threshold:0.16;-sfsymbols-motion-group:2;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.hierarchical-11:primary {-sfsymbols-variable-threshold:0.09;-sfsymbols-motion-group:1;-sfsymbols-layer-tags:-4ed3e2b5f369b4bf}
.SFSymbolsPreviewWireframe {fill:none;opacity:1.0;stroke:black;stroke-width:0.5}
</style>
<g id="Notes">
<rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 856.422 322)">Thin</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1153.13 322)">Light</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1449.84 322)">Regular</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1746.56 322)">Medium</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2043.27 322)">Semibold</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2339.98 322)">Bold</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2636.69 322)">Heavy</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2933.4 322)">Black</text>
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1903" y2="1903"/>
<g transform="matrix(0.2 0 0 0.2 263 1933)">
<path d="m46.2402 4.15039c21.7773 0 39.4531-17.627 39.4531-39.4043s-17.6758-39.4043-39.4531-39.4043c-21.7285 0-39.4043 17.627-39.4043 39.4043s17.6758 39.4043 39.4043 39.4043Zm0-7.42188c-17.6758 0-31.9336-14.3066-31.9336-31.9824s14.2578-31.9824 31.9336-31.9824 31.9824 14.3066 31.9824 31.9824-14.3066 31.9824-31.9824 31.9824Zm-17.9688-31.9824c0 2.14844 1.51367 3.61328 3.75977 3.61328h10.498v10.5957c0 2.19727 1.46484 3.71094 3.61328 3.71094 2.24609 0 3.71094-1.51367 3.71094-3.71094v-10.5957h10.5957c2.19727 0 3.71094-1.46484 3.71094-3.61328 0-2.19727-1.51367-3.71094-3.71094-3.71094h-10.5957v-10.5469c0-2.24609-1.46484-3.75977-3.71094-3.75977-2.14844 0-3.61328 1.51367-3.61328 3.75977v10.5469h-10.498c-2.24609 0-3.75977 1.51367-3.75977 3.71094Z"/>
</g>
<g transform="matrix(0.2 0 0 0.2 281.506 1933)">
<path d="m58.5449 14.5508c27.4902 0 49.8047-22.3145 49.8047-49.8047s-22.3145-49.8047-49.8047-49.8047-49.8047 22.3145-49.8047 49.8047 22.3145 49.8047 49.8047 49.8047Zm0-8.30078c-22.9492 0-41.5039-18.5547-41.5039-41.5039s18.5547-41.5039 41.5039-41.5039 41.5039 18.5547 41.5039 41.5039-18.5547 41.5039-41.5039 41.5039Zm-22.6562-41.5039c0 2.39258 1.66016 4.00391 4.15039 4.00391h14.3555v14.4043c0 2.44141 1.66016 4.15039 4.05273 4.15039 2.44141 0 4.15039-1.66016 4.15039-4.15039v-14.4043h14.4043c2.44141 0 4.15039-1.61133 4.15039-4.00391 0-2.44141-1.70898-4.15039-4.15039-4.15039h-14.4043v-14.3555c0-2.49023-1.70898-4.19922-4.15039-4.19922-2.39258 0-4.05273 1.70898-4.05273 4.19922v14.3555h-14.3555c-2.49023 0-4.15039 1.70898-4.15039 4.15039Z"/>
</g>
<g transform="matrix(0.2 0 0 0.2 304.924 1933)">
<path d="m74.8535 28.3203c35.1074 0 63.623-28.4668 63.623-63.5742s-28.5156-63.623-63.623-63.623-63.5742 28.5156-63.5742 63.623 28.4668 63.5742 63.5742 63.5742Zm0-9.08203c-30.127 0-54.4922-24.3652-54.4922-54.4922s24.3652-54.4922 54.4922-54.4922 54.4922 24.3652 54.4922 54.4922-24.3652 54.4922-54.4922 54.4922Zm-28.8574-54.4922c0 2.58789 1.85547 4.39453 4.58984 4.39453h19.7266v19.7754c0 2.68555 1.85547 4.58984 4.44336 4.58984 2.68555 0 4.54102-1.85547 4.54102-4.58984v-19.7754h19.7754c2.68555 0 4.58984-1.80664 4.58984-4.39453 0-2.73438-1.85547-4.58984-4.58984-4.58984h-19.7754v-19.7266c0-2.73438-1.85547-4.63867-4.54102-4.63867-2.58789 0-4.44336 1.9043-4.44336 4.63867v19.7266h-19.7266c-2.73438 0-4.58984 1.85547-4.58984 4.58984Z"/>
</g>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 1953)">Design Variations</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1971)">Symbols are supported in up to nine weights and three scales.</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1989)">For optimal layout with text and other symbols, vertically align</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 2007)">symbols with the adjacent text.</text>
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="776" x2="776" y1="1919" y2="1933"/>
<g transform="matrix(0.2 0 0 0.2 776 1933)">
<path d="m16.5527 0.78125c2.58789 0 3.85742-0.976562 4.78516-3.71094l6.29883-17.2363h28.8086l6.29883 17.2363c0.927734 2.73438 2.19727 3.71094 4.73633 3.71094 2.58789 0 4.24805-1.5625 4.24805-4.00391 0-0.830078-0.146484-1.61133-0.537109-2.63672l-22.9004-60.9863c-1.12305-2.97852-3.125-4.49219-6.25-4.49219-3.02734 0-5.07812 1.46484-6.15234 4.44336l-22.9004 61.084c-0.390625 1.02539-0.537109 1.80664-0.537109 2.63672 0 2.44141 1.5625 3.95508 4.10156 3.95508Zm13.4766-28.3691 11.8652-32.8613h0.244141l11.8652 32.8613Z"/>
</g>
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="792.836" x2="792.836" y1="1919" y2="1933"/>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 776 1953)">Margins</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1971)">Leading and trailing margins on the left and right side of each symbol</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1989)">can be adjusted by modifying the x-location of the margin guidelines.</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2007)">Modifications are automatically applied proportionally to all</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2025)">scales and weights.</text>
<g transform="matrix(0.2 0 0 0.2 1289 1933)">
<path d="m14.209 9.32617 8.49609 8.54492c4.29688 4.3457 9.22852 4.05273 13.8672-1.07422l53.4668-58.9355-4.83398-4.88281-53.0762 58.3984c-1.75781 2.00195-3.41797 2.49023-5.76172 0.146484l-5.85938-5.81055c-2.34375-2.29492-1.80664-4.00391 0.195312-5.81055l57.373-54.0039-4.88281-4.83398-57.959 54.4434c-4.93164 4.58984-5.32227 9.47266-1.02539 13.8184Zm32.0801-90.9668c-2.09961 2.05078-2.24609 4.93164-1.07422 6.88477 1.17188 1.80664 3.4668 2.97852 6.68945 2.14844 7.32422-1.70898 14.9414-2.00195 22.0703 2.68555l-2.92969 7.27539c-1.70898 4.15039-0.830078 7.08008 1.85547 9.81445l11.4746 11.5723c2.44141 2.44141 4.49219 2.53906 7.32422 2.05078l5.32227-0.976562 3.32031 3.36914-0.195312 2.7832c-0.195312 2.49023 0.439453 4.39453 2.88086 6.78711l3.80859 3.71094c2.39258 2.39258 5.46875 2.53906 7.8125 0.195312l14.5508-14.5996c2.34375-2.34375 2.24609-5.32227-0.146484-7.71484l-3.85742-3.80859c-2.39258-2.39258-4.24805-3.17383-6.64062-2.97852l-2.88086 0.244141-3.22266-3.17383 1.2207-5.61523c0.634766-2.83203-0.146484-5.0293-3.07617-7.95898l-10.9863-10.9375c-16.6992-16.6016-38.8672-16.2109-53.3203-1.75781Zm7.4707 1.85547c12.1582-8.88672 28.6133-7.37305 39.7461 3.75977l12.1582 12.0605c1.17188 1.17188 1.36719 2.09961 1.02539 3.80859l-1.61133 7.42188 7.51953 7.42188 4.93164-0.292969c1.26953-0.0488281 1.66016 0.0488281 2.63672 1.02539l2.88086 2.88086-12.207 12.207-2.88086-2.88086c-0.976562-0.976562-1.12305-1.36719-1.07422-2.68555l0.341797-4.88281-7.4707-7.42188-7.61719 1.26953c-1.61133 0.341797-2.34375 0.195312-3.56445-0.976562l-10.0098-10.0098c-1.26953-1.17188-1.41602-2.00195-0.634766-3.85742l4.39453-10.4492c-7.8125-7.27539-17.9688-10.4004-28.125-7.42188-0.78125 0.195312-1.07422-0.439453-0.439453-0.976562Z"/>
</g>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 1289 1953)">Exporting</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1971)">Symbols should be outlined when exporting to ensure the</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1989)">design is preserved when submitting to Xcode.</text>
<text id="template-version" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.6.0</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1951)">Requires Xcode 16 or greater</text>
<text id="descriptive-name" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1969)">Generated from progress.ring.dashed</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1987)">Typeset at 100.0 points</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 726)">Small</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1156)">Medium</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1586)">Large</text>
</g>
<g id="Guides">
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 696)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
<line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1126)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
<line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1556)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1556" y2="1556"/>
<line id="Capline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1485.54" y2="1485.54"/>
<line id="right-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2984.21" x2="2984.21" y1="600.785" y2="720.121"/>
<line id="left-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2882.59" x2="2882.59" y1="600.785" y2="720.121"/>
<line id="right-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1499.82" x2="1499.82" y1="600.785" y2="720.121"/>
<line id="left-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1399.87" x2="1399.87" y1="600.785" y2="720.121"/>
<line id="right-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="607.643" x2="607.643" y1="600.785" y2="720.121"/>
<line id="left-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="511.779" x2="511.779" y1="600.785" y2="720.121"/>
</g>
<g id="Symbols">
<g id="Black-S" transform="matrix(1 0 0 1 2882.59 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" d="M40.5762-61.1816C42.5293-61.9141 44.5801-62.4512 46.7285-62.793C47.9492-62.9883 48.8281-63.916 48.8281-65.1855L48.8281-76.9043C48.8281-78.4668 47.6562-79.5898 46.0938-79.4434C41.4062-78.9551 36.9141-77.7344 32.7637-75.8301C31.3477-75.1953 30.9082-73.6328 31.6895-72.3145L37.5488-62.1582C38.1836-61.084 39.4043-60.6934 40.5762-61.1816Z"/>
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:primary SFSymbolsPreviewWireframe" d="M29.0039-52.5879C30.3223-54.248 31.7871-55.7129 33.4473-57.0312C34.4727-57.8125 34.7656-59.082 34.1309-60.1562L28.2715-70.3125C27.4902-71.6797 25.8789-72.0703 24.6094-71.1426C20.8984-68.4082 17.627-65.1367 14.8926-61.4258C13.9648-60.1074 14.3555-58.5449 15.7227-57.7637L25.8789-51.9043C26.9531-51.2695 28.2227-51.5625 29.0039-52.5879Z"/>
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M23.291-39.3555C23.584-41.4551 24.1211-43.5059 24.9023-45.4102C25.3906-46.6309 25-47.8516 23.877-48.4863L13.7207-54.3457C12.4023-55.127 10.8398-54.6875 10.2051-53.2715C8.30078-49.1211 7.08008-44.6289 6.5918-39.9414C6.44531-38.3789 7.56836-37.207 9.13086-37.207L20.8984-37.207C22.168-37.207 23.0957-38.0859 23.291-39.3555Z"/>
<path class="monochrome-3 multicolor-3:tintColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M24.8535-25.0977C24.1211-27.002 23.584-29.0039 23.291-31.1523C23.0957-32.4219 22.168-33.3008 20.8984-33.3008L9.13086-33.3008C7.56836-33.3008 6.44531-32.1289 6.5918-30.5664C7.08008-25.8789 8.30078-21.3867 10.2051-17.2363C10.8398-15.8203 12.4023-15.3809 13.7207-16.1621L23.877-22.0215C25-22.6562 25.3418-23.877 24.8535-25.0977Z"/>
<path class="monochrome-4 multicolor-4:tintColor hierarchical-4:secondary SFSymbolsPreviewWireframe" d="M33.4473-13.4766C31.8359-14.7949 30.3223-16.2598 29.0039-17.9199C28.2227-18.9453 26.9531-19.2383 25.8789-18.6035L15.7227-12.7441C14.3555-11.9629 13.9648-10.4004 14.8926-9.08203C17.627-5.37109 20.8984-2.05078 24.6094 0.634766C25.8789 1.5625 27.4902 1.12305 28.2715-0.195312L34.1309-10.3516C34.7656-11.4258 34.4727-12.6953 33.4473-13.4766Z"/>
<path class="monochrome-5 multicolor-5:tintColor hierarchical-5:tertiary SFSymbolsPreviewWireframe" d="M46.7285-7.71484C44.5801-8.05664 42.5293-8.59375 40.5762-9.32617C39.4043-9.81445 38.1836-9.42383 37.5488-8.34961L31.6895 1.80664C30.9082 3.125 31.3477 4.6875 32.7637 5.32227C36.9141 7.22656 41.4062 8.44727 46.0938 8.93555C47.6562 9.08203 48.8281 7.95898 48.8281 6.39648L48.8281-5.32227C48.8281-6.5918 47.9492-7.51953 46.7285-7.71484Z"/>
<path class="monochrome-6 multicolor-6:tintColor hierarchical-6:primary SFSymbolsPreviewWireframe" d="M61.0352-9.32617C59.082-8.59375 57.0312-8.05664 54.8828-7.71484C53.6133-7.51953 52.7344-6.5918 52.7344-5.32227L52.7344 6.39648C52.7344 7.95898 53.9062 9.08203 55.4688 8.93555C60.1562 8.44727 64.6484 7.22656 68.8477 5.32227C70.2637 4.6875 70.7031 3.07617 69.9219 1.75781L64.0625-8.34961C63.4277-9.42383 62.207-9.81445 61.0352-9.32617Z"/>
<path class="monochrome-7 multicolor-7:tintColor hierarchical-7:primary SFSymbolsPreviewWireframe" d="M72.6074-17.9199C71.2891-16.2598 69.8242-14.7949 68.1641-13.4766C67.1387-12.6953 66.8457-11.4258 67.4805-10.3516L73.3398-0.195312C74.1211 1.17188 75.7324 1.5625 77.002 0.634766C80.7129-2.09961 83.9844-5.37109 86.7188-9.13086C87.5977-10.4004 87.207-11.9629 85.8887-12.7441L75.7324-18.6035C74.6582-19.2383 73.3887-18.9453 72.6074-17.9199Z"/>
<path class="monochrome-8 multicolor-8:tintColor hierarchical-8:primary SFSymbolsPreviewWireframe" d="M78.3203-31.1523C78.0273-29.0039 77.4902-27.002 76.709-25.0488C76.2207-23.877 76.6113-22.6562 77.6855-22.0215L87.8418-16.1621C89.1602-15.3809 90.7715-15.8203 91.4062-17.2852C93.2617-21.3867 94.4824-25.8789 94.9707-30.5664C95.1172-32.1289 93.9453-33.3008 92.4316-33.3008L80.7129-33.3008C79.4434-33.3008 78.5156-32.4219 78.3203-31.1523Z"/>
<path class="monochrome-9 multicolor-9:tintColor hierarchical-9:primary SFSymbolsPreviewWireframe" d="M76.709-45.459C77.4902-43.5059 78.0273-41.5039 78.3203-39.3555C78.5156-38.0859 79.4434-37.207 80.7129-37.207L92.4316-37.207C93.9453-37.207 95.1172-38.3789 94.9707-39.9414C94.5312-44.5801 93.2617-49.0723 91.4062-53.2227C90.7715-54.6875 89.1602-55.127 87.8418-54.3457L77.6855-48.4863C76.6113-47.8516 76.2207-46.6309 76.709-45.459Z"/>
<path class="monochrome-10 multicolor-10:tintColor hierarchical-10:primary SFSymbolsPreviewWireframe" d="M68.1641-57.0312C69.7754-55.7129 71.2891-54.248 72.6074-52.5879C73.3887-51.5625 74.6582-51.2695 75.7324-51.9043L85.8887-57.7637C87.207-58.5449 87.5977-60.1074 86.7188-61.377C83.9844-65.1367 80.7129-68.457 77.002-71.1426C75.7324-72.0703 74.1211-71.6309 73.3398-70.3125L67.4805-60.1562C66.8457-59.082 67.1387-57.8125 68.1641-57.0312Z"/>
<path class="monochrome-11 multicolor-11:tintColor hierarchical-11:primary SFSymbolsPreviewWireframe" d="M54.834-62.793C57.0312-62.4512 59.082-61.9141 61.0352-61.1816C62.207-60.6934 63.4277-61.084 64.0625-62.1582L69.9219-72.3145C70.7031-73.6328 70.2637-75.1953 68.8477-75.8301C64.6973-77.7344 60.2051-78.9551 55.4688-79.4434C53.9062-79.5898 52.7344-78.4668 52.7344-76.9043L52.7344-65.1855C52.7344-63.916 53.6133-62.9883 54.834-62.793Z"/>
</g>
<g id="Regular-S" transform="matrix(1 0 0 1 1399.87 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" d="M38.623-61.4746C41.1621-62.5977 43.8477-63.3301 46.7285-63.6719C47.5098-63.7207 48.0469-64.3066 48.0469-65.0879L48.0469-76.709C48.0469-77.6367 47.3633-78.3691 46.4355-78.2715C41.1621-77.832 36.1816-76.3672 31.5918-74.2188C30.7617-73.8281 30.5176-72.8516 30.957-72.0703L36.6699-62.1094C37.1094-61.4258 37.8906-61.1816 38.623-61.4746Z"/>
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:primary SFSymbolsPreviewWireframe" d="M27.0508-52.2949C28.7109-54.541 30.6641-56.543 32.959-58.2031C33.5938-58.6914 33.7402-59.4727 33.3496-60.1562L27.5879-70.1172C27.1484-70.8984 26.1719-71.1426 25.3906-70.6055C21.1914-67.627 17.5781-64.0137 14.6484-59.8145C14.1113-59.0332 14.3066-58.0566 15.1367-57.5684L25.1465-51.8555C25.8301-51.4648 26.5625-51.6113 27.0508-52.2949Z"/>
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M21.582-38.5254C21.9238-41.3574 22.6562-44.0918 23.7305-46.582C24.0723-47.3145 23.8281-48.0957 23.1445-48.4863L13.1348-54.248C12.3047-54.7363 11.377-54.4434 10.9863-53.6133C8.83789-49.0234 7.42188-44.0918 6.98242-38.8184C6.93359-37.8906 7.56836-37.207 8.54492-37.207L20.166-37.207C20.9473-37.207 21.5332-37.7441 21.582-38.5254Z"/>
<path class="monochrome-3 multicolor-3:tintColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M23.7305-23.877C22.6562-26.3672 21.9238-29.1016 21.582-31.9824C21.5332-32.7637 20.9473-33.3008 20.166-33.3008L8.49609-33.3008C7.56836-33.3008 6.88477-32.6172 6.98242-31.6895C7.42188-26.3672 8.83789-21.3867 11.0352-16.7969C11.4258-15.9668 12.3047-15.6738 13.1348-16.1621L23.1445-21.9727C23.8281-22.3633 24.0723-23.1445 23.7305-23.877Z"/>
<path class="monochrome-4 multicolor-4:tintColor hierarchical-4:secondary SFSymbolsPreviewWireframe" d="M32.959-12.2559C30.7129-13.916 28.7109-15.8691 27.0508-18.1641C26.5625-18.7988 25.7812-18.9941 25.0977-18.5547L15.1367-12.8906C14.3066-12.4023 14.1113-11.4258 14.5996-10.6445C17.5781-6.44531 21.1914-2.83203 25.4395 0.146484C26.2207 0.683594 27.1973 0.439453 27.6367-0.341797L33.3496-10.3516C33.7402-11.0352 33.5938-11.7676 32.959-12.2559Z"/>
<path class="monochrome-5 multicolor-5:tintColor hierarchical-5:tertiary SFSymbolsPreviewWireframe" d="M46.7285-6.78711C43.8965-7.12891 41.1621-7.86133 38.6719-8.93555C37.9395-9.27734 37.1582-9.0332 36.7188-8.34961L31.0059 1.61133C30.5664 2.39258 30.8105 3.36914 31.6406 3.75977C36.1816 5.9082 41.1621 7.37305 46.4355 7.8125C47.3633 7.86133 48.0469 7.17773 48.0469 6.25L48.0469-5.37109C48.0469-6.15234 47.5098-6.73828 46.7285-6.78711Z"/>
<path class="monochrome-6 multicolor-6:tintColor hierarchical-6:primary SFSymbolsPreviewWireframe" d="M61.377-8.93555C58.8867-7.86133 56.1523-7.12891 53.2715-6.78711C52.4414-6.73828 51.9043-6.15234 51.9043-5.37109L51.9043 6.25C51.9043 7.22656 52.5879 7.91016 53.5645 7.8125C58.7891 7.37305 63.8672 5.9082 68.4082 3.75977C69.2383 3.36914 69.4824 2.39258 69.043 1.61133L63.2812-8.34961C62.8906-9.0332 62.1094-9.27734 61.377-8.93555Z"/>
<path class="monochrome-7 multicolor-7:tintColor hierarchical-7:primary SFSymbolsPreviewWireframe" d="M72.998-18.1641C71.3379-15.918 69.2871-13.9648 67.041-12.2559C66.4062-11.7676 66.2109-10.9863 66.6016-10.3516L72.3633-0.390625C72.8027 0.390625 73.7793 0.683594 74.5605 0.146484C78.8086-2.83203 82.4219-6.49414 85.3516-10.6445C85.8887-11.4258 85.6445-12.4023 84.8633-12.8906L74.9023-18.6035C74.2676-19.043 73.4863-18.7988 72.998-18.1641Z"/>
<path class="monochrome-8 multicolor-8:tintColor hierarchical-8:primary SFSymbolsPreviewWireframe" d="M78.418-31.9824C78.0762-29.1016 77.3438-26.3672 76.2207-23.877C75.9277-23.1445 76.1719-22.4121 76.8555-22.0215L86.8164-16.2598C87.6465-15.7715 88.623-16.0156 89.0137-16.8945C91.1621-21.4844 92.5293-26.416 92.9688-31.6895C93.0176-32.6172 92.3828-33.3008 91.4551-33.3008L79.8828-33.3008C79.1016-33.3008 78.4668-32.7637 78.418-31.9824Z"/>
<path class="monochrome-9 multicolor-9:tintColor hierarchical-9:primary SFSymbolsPreviewWireframe" d="M76.2207-46.6309C77.2949-44.0918 78.0762-41.3574 78.418-38.5254C78.4668-37.7441 79.1016-37.207 79.8828-37.207L91.4551-37.207C92.3828-37.207 93.0176-37.8906 92.9688-38.8184C92.5293-44.043 91.1621-49.0723 89.0137-53.6133C88.623-54.4434 87.5977-54.7363 86.8164-54.248L76.8066-48.4375C76.1719-48.0469 75.9277-47.3145 76.2207-46.6309Z"/>
<path class="monochrome-10 multicolor-10:tintColor hierarchical-10:primary SFSymbolsPreviewWireframe" d="M67.041-58.2031C69.2871-56.543 71.2891-54.5898 72.998-52.2949C73.4863-51.6602 74.2676-51.416 74.9023-51.8555L84.8633-57.5684C85.6934-58.0566 85.8887-59.0332 85.4004-59.8145C82.4219-64.0137 78.7598-67.6758 74.5605-70.6055C73.7793-71.1426 72.8027-70.8984 72.3633-70.1172L66.6016-60.1562C66.2109-59.4727 66.3574-58.6914 67.041-58.2031Z"/>
<path class="monochrome-11 multicolor-11:tintColor hierarchical-11:primary SFSymbolsPreviewWireframe" d="M53.2715-63.6719C56.1523-63.3789 58.8379-62.5977 61.377-61.4746C62.1094-61.1816 62.8906-61.4258 63.2812-62.1094L69.043-72.0703C69.4824-72.8516 69.1895-73.8281 68.3594-74.2188C63.7695-76.3672 58.8379-77.832 53.5645-78.2715C52.6367-78.3203 51.9043-77.6367 51.9043-76.709L51.9043-65.0879C51.9043-64.3066 52.4414-63.7207 53.2715-63.6719Z"/>
</g>
<g id="Ultralight-S" transform="matrix(1 0 0 1 511.779 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" d="M36.5342-61.1113C39.1187-62.2344 41.895-63.0122 44.7759-63.354C45.6934-63.4482 46.3213-64.1704 46.3213-65.0425L46.3213-74.4839C46.3213-75.5479 45.5469-76.3257 44.4829-76.228C39.4365-75.834 34.6831-74.5054 30.3657-72.4024C29.3994-71.9663 29.1099-70.8989 29.6401-69.9815L34.354-61.7915C34.7935-61.0171 35.6655-60.773 36.5342-61.1113Z"/>
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:primary SFSymbolsPreviewWireframe" d="M25.0982-51.9771C26.8037-54.2686 28.8477-56.3159 31.1426-58.0669C31.9136-58.6006 32.1055-59.5181 31.6694-60.2925L26.9522-68.437C26.4219-69.3545 25.3545-69.5986 24.4824-69.0161C20.4194-66.2192 16.9424-62.6514 14.1489-58.6338C13.5664-57.7617 13.8071-56.6943 14.728-56.1607L22.876-51.4468C23.6504-50.9653 24.5645-51.2026 25.0982-51.9771Z"/>
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M19.7656-38.3438C20.062-41.2666 20.8399-44.001 22.0049-46.582C22.3467-47.4053 22.1026-48.2773 21.2827-48.7588L13.1348-53.4761C12.2139-54.0098 11.1499-53.6714 10.7139-52.7051C8.70166-48.3877 7.37647-43.6831 6.93701-38.6821C6.88818-37.6182 7.65918-36.7983 8.72656-36.7983L18.1226-36.7983C19.0401-36.7983 19.6714-37.4263 19.7656-38.3438Z"/>
<path class="monochrome-3 multicolor-3:tintColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M22.0049-23.8315C20.8853-26.4126 20.1528-29.1924 19.811-32.1187C19.7168-33.0361 18.9946-33.6641 18.1226-33.6641L8.67773-33.6641C7.61377-33.6641 6.83936-32.8896 6.93701-31.8257C7.33106-26.7759 8.65625-22.0225 10.7173-17.6597C11.1533-16.6934 12.2593-16.355 13.1802-16.8887L21.3281-21.6548C22.148-22.1362 22.3921-23.0083 22.0049-23.8315Z"/>
<path class="monochrome-4 multicolor-4:tintColor hierarchical-4:secondary SFSymbolsPreviewWireframe" d="M31.188-12.3921C28.8965-14.0977 26.8491-16.1416 25.1436-18.4365C24.6099-19.1621 23.7378-19.4028 22.918-18.918L14.7734-14.2529C13.8525-13.7192 13.5664-12.6518 14.1455-11.7798C16.9424-7.7622 20.4648-4.28514 24.4858-1.44286C25.3579-0.81493 26.4253-1.05907 26.9556-2.02196L31.6694-10.1699C32.1055-10.9443 31.9136-11.8584 31.188-12.3921Z"/>
<path class="monochrome-5 multicolor-5:tintColor hierarchical-5:tertiary SFSymbolsPreviewWireframe" d="M44.7759-7.10498C41.8985-7.40137 39.1641-8.1792 36.583-9.29883C35.7598-9.68603 34.8423-9.39648 34.4029-8.62207L29.689-0.432113C29.1587 0.485367 29.4483 1.55275 30.4146 1.98878C34.6831 4.0464 39.4365 5.37502 44.4375 5.81447C45.5015 5.8633 46.3213 5.08888 46.3213 4.02492L46.3213-5.4165C46.3213-6.28857 45.6934-7.01074 44.7759-7.10498Z"/>
<path class="monochrome-6 multicolor-6:tintColor hierarchical-6:primary SFSymbolsPreviewWireframe" d="M59.2881-9.34424C56.7525-8.22461 53.9727-7.44677 51.0464-7.10498C50.1255-7.01074 49.4976-6.28857 49.4976-5.4165L49.4976 4.07033C49.4976 5.13771 50.272 5.91213 51.3394 5.81447C56.3823 5.42043 61.1426 4.09181 65.502 1.98878C66.4683 1.55275 66.7578 0.485367 66.2276-0.432113L61.4649-8.62207C61.0288-9.39648 60.1113-9.68603 59.2881-9.34424Z"/>
<path class="monochrome-7 multicolor-7:tintColor hierarchical-7:primary SFSymbolsPreviewWireframe" d="M70.7276-18.4819C69.022-16.1904 66.9712-14.1465 64.6343-12.3921C63.9087-11.8584 63.7134-10.9409 64.1494-10.1699L68.9121-2.02538C69.4424-1.06249 70.5098-0.81493 71.3819-1.44286C75.4483-4.23974 78.9253-7.76561 81.7188-11.7798C82.3013-12.6518 82.0572-13.7192 81.1397-14.2529L72.9497-18.9668C72.1333-19.4517 71.2612-19.2075 70.7276-18.4819Z"/>
<path class="monochrome-8 multicolor-8:tintColor hierarchical-8:primary SFSymbolsPreviewWireframe" d="M76.0567-32.1187C75.7603-29.1924 74.9824-26.4126 73.814-23.8315C73.4756-23.0083 73.7197-22.1397 74.5396-21.6582L82.6841-16.9409C83.605-16.4072 84.6724-16.6968 85.1084-17.6665C87.166-21.9839 88.4878-26.7339 88.9273-31.7803C88.9761-32.8442 88.2051-33.6641 87.1411-33.6641L77.7031-33.6641C76.7857-33.6641 76.1509-33.0361 76.0567-32.1187Z"/>
<path class="monochrome-9 multicolor-9:tintColor hierarchical-9:primary SFSymbolsPreviewWireframe" d="M73.814-46.5854C74.9336-44.001 75.7149-41.2212 76.0112-38.3438C76.1509-37.4263 76.7857-36.7983 77.7031-36.7983L87.1866-36.7983C88.2505-36.7983 89.0215-37.5728 88.9273-38.6367C88.5332-43.6797 87.2115-48.4365 85.1084-52.7959C84.6724-53.7622 83.6016-54.0552 82.6841-53.5215L74.4907-48.7554C73.6743-48.2739 73.4756-47.4507 73.814-46.5854Z"/>
<path class="monochrome-10 multicolor-10:tintColor hierarchical-10:primary SFSymbolsPreviewWireframe" d="M64.6797-58.0215C66.9712-56.3159 69.0186-54.272 70.7276-51.9771C71.2612-51.2515 72.1333-51.0073 72.9497-51.4922L81.0943-56.1607C82.0152-56.6943 82.3013-57.7617 81.7222-58.6338C78.9707-62.6514 75.4449-66.1318 71.4273-68.9707C70.5552-69.5986 69.4424-69.3545 68.9121-68.3916L64.1948-60.2471C63.7588-59.4727 63.9507-58.5552 64.6797-58.0215Z"/>
<path class="monochrome-11 multicolor-11:tintColor hierarchical-11:primary SFSymbolsPreviewWireframe" d="M51.0464-63.354C53.9727-63.061 56.7036-62.2798 59.2881-61.1113C60.1113-60.773 61.0288-61.0625 61.4649-61.8369L66.2276-69.9815C66.7578-70.8989 66.4195-71.9663 65.4531-72.4024C61.1358-74.46 56.3858-75.7886 51.3848-76.228C50.3208-76.2769 49.4976-75.5025 49.4976-74.4385L49.4976-65.0425C49.4976-64.1704 50.1255-63.4482 51.0464-63.354Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -0,0 +1,47 @@
//
// iCloudStats.swift
// Meshtastic
//
// Copyright(c) Garth Vander Houwen 6/18/25.
//
enum KeyBackupStatus: String, CaseIterable, Equatable, Decodable {
case saved
case restored
case deleted
case saveFailed
case restoreFailed
case deleteFailed
var description: String {
switch self {
case .saved:
return "Private Key saved successfully to iCloud keychain.".localized
case .restored:
return "Private Key restored successfully from iCloud keychain.".localized
case .deleted:
return "Private Key deleted successfully from iCloud keychain.".localized
case .saveFailed:
return "Private Key failed to save to iCloud keychain.".localized
case .restoreFailed:
return "Private Key value not found in iCloud keychain.".localized
case .deleteFailed:
return "Private Key failed to delete from iCloud keychain.".localized
}
}
var success: Bool {
switch self {
case .saved:
return true
case .restored:
return true
case .deleted:
return true
case .saveFailed:
return false
case .restoreFailed:
return false
case .deleteFailed:
return false
}
}
}

View file

@ -37,17 +37,17 @@ enum ActivityType: Int, CaseIterable, Identifiable {
var fileNameString: String {
switch self {
case .walking:
return "walk".localized
return "Walking".localized.lowercased()
case .hiking:
return "hiking".localized
return "Hiking".localized.lowercased()
case .biking:
return "biking".localized
return "Biking".localized.lowercased()
case .driving:
return "driving".localized
return "Driving".localized.lowercased()
case .overlanding:
return "overlanding".localized
return "Overlanding".localized.lowercased()
case .skiing:
return "skiing".localized
return "Skiing".localized.lowercased()
}
}
}

View file

@ -174,7 +174,7 @@ enum SerialTimeoutIntervals: Int, CaseIterable, Identifiable {
case .tenSeconds:
return "Ten Seconds".localized
case .fifteenSeconds:
return "Thirty Seconds".localized
return "Fifteen Seconds".localized
case .thirtySeconds:
return "Thirty Seconds".localized
case .oneMinute:

View file

@ -32,6 +32,7 @@ extension ChannelEntity {
channel.settings.psk = self.psk ?? Data()
channel.role = Channel.Role(rawValue: Int(self.role)) ?? Channel.Role.secondary
channel.settings.moduleSettings.positionPrecision = UInt32(self.positionPrecision)
channel.settings.moduleSettings.isClientMuted = self.mute
return channel
}
}

View file

@ -31,4 +31,11 @@ extension MessageEntity {
return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
}
func displayTimestamp(aboveMessage: MessageEntity?) -> Bool {
if let aboveMessage = aboveMessage {
return aboveMessage.timestamp.addingTimeInterval(3600) < timestamp // 60 minutes
}
return false // First message will have no timestamp
}
}

View file

@ -0,0 +1,30 @@
// NodeInfoEntityToNodeInfo.swift
// Meshtastic
//
// Utility to convert NodeInfoEntity (Core Data) to NodeInfo (protobuf)
import Foundation
import MeshtasticProtobufs
extension NodeInfoEntity {
func toProto() -> NodeInfo {
var userProto = User()
if let user = self.user {
userProto.id = user.userId ?? ""
userProto.longName = user.longName ?? ""
userProto.shortName = user.shortName ?? ""
userProto.hwModel = HardwareModel(rawValue: Int(user.hwModelId)) ?? .unset
userProto.isLicensed = user.isLicensed
if userProto.hasIsUnmessagable == true {
userProto.isUnmessagable = user.unmessagable
}
userProto.role = Config.DeviceConfig.Role(rawValue: Int(user.role)) ?? .client
userProto.publicKey = user.publicKey?.subdata(in: 0..<user.publicKey!.count) ?? Data()
}
var node = NodeInfo()
node.num = UInt32(self.num)
node.user = userProto
// Add more fields as needed
return node
}
}

View file

@ -106,14 +106,42 @@ extension UserEntity {
}
}
}
public func createUser(num: Int64, context: NSManagedObjectContext) -> UserEntity {
let newUser = UserEntity(context: context)
newUser.num = Int64(num)
let userId = String(format: "%2X", num)
newUser.userId = "!\(userId)"
let last4 = String(userId.suffix(4))
newUser.longName = "Meshtastic \(last4)"
newUser.shortName = last4
newUser.hwModel = "UNSET"
public func createUser(num: Int64, context: NSManagedObjectContext) throws -> UserEntity {
// Validate Input
guard num >= 0 else {
throw CoreDataError.invalidInput(message: "User number cannot be negative.")
}
var newUser: UserEntity! // Use an implicitly unwrapped optional, but ensure it's assigned
context.performAndWait {
newUser = UserEntity(context: context)
newUser.num = num
let userId = num.toHex()
newUser.userId = userId
let last4 = String(userId.suffix(4))
newUser.longName = "Meshtastic \(last4)"
newUser.shortName = last4
newUser.hwModel = "UNSET"
}
return newUser
}
enum CoreDataError: Error, LocalizedError {
case invalidInput(message: String)
case saveFailed(message: String)
case entityCreationFailed(message: String) // In case UserEntity(context:) fails for some reason
var errorDescription: String? {
switch self {
case .invalidInput(let message):
return "Core Data Input Error: \(message)"
case .saveFailed(let message):
return "Core Data Save Error: \(message)"
case .entityCreationFailed(let message):
return "Core Data Entity Creation Error: \(message)"
}
}
}

View file

@ -14,9 +14,6 @@ extension WaypointEntity {
static func allWaypointssFetchRequest() -> NSFetchRequest<WaypointEntity> {
let request: NSFetchRequest<WaypointEntity> = WaypointEntity.fetchRequest()
request.fetchLimit = 50
// request.fetchBatchSize = 1
// request.returnsObjectsAsFaults = false
// request.includesSubentities = true
request.returnsDistinctResults = true
request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: false)]
request.predicate = NSPredicate(format: "expire == nil || expire >= %@", Date() as NSDate)
@ -24,7 +21,6 @@ extension WaypointEntity {
}
var latitude: Double? {
let d = Double(latitudeI)
if d == 0 {
return 0
@ -33,7 +29,6 @@ extension WaypointEntity {
}
var longitude: Double? {
let d = Double(longitudeI)
if d == 0 {
return 0
@ -46,7 +41,7 @@ extension WaypointEntity {
let coord = CLLocationCoordinate2D(latitude: latitude!, longitude: longitude!)
return coord
} else {
return nil
return nil
}
}
@ -60,16 +55,29 @@ extension WaypointEntity {
}
extension WaypointEntity: MKAnnotation {
public var coordinate: CLLocationCoordinate2D { waypointCoordinate ?? LocationsHandler.DefaultLocation }
public var title: String? { name ?? "Dropped Pin" }
@MainActor
public var coordinate: CLLocationCoordinate2D {
get {
waypointCoordinate ?? LocationsHandler.DefaultLocation
}
set {
latitudeI = Int32(newValue.latitude * 1e7)
longitudeI = Int32(newValue.longitude * 1e7)
}
}
public var title: String? {
name ?? "Dropped Pin"
}
public var subtitle: String? {
(longDescription ?? "") +
String(expire != nil ? "\n⌛ Expires \(String(describing: expire?.formatted()))" : "") +
String(locked > 0 ? "\n🔒 Locked" : "") }
String(locked > 0 ? "\n🔒 Locked" : "")
}
}
struct WaypointCoordinate: Identifiable {
let id: UUID
let coordinate: CLLocationCoordinate2D?
let waypointId: Int64

View file

@ -115,6 +115,17 @@ extension String {
.joined()
}
/// Formats a short name like "P130" to read as "Node P 130" for VoiceOver
/// This ensures proper pronunciation of alphanumeric node IDs
func formatNodeNameForVoiceOver() -> String {
let spaced = self.replacingOccurrences(
of: #"([A-Za-z])([0-9]+)"#,
with: "$1 $2",
options: .regularExpression
)
return "Node".localized + " " + spaced
}
// Adds variation selectors to prefer the graphical form of emoji.
// Looks ahead to make sure that the variation selector is not already applied.
var addingVariationSelectors: String {

View file

@ -57,6 +57,7 @@ extension UserDefaults {
case enableMapTraffic
case enableMapPointsOfInterest
case enableOfflineMaps
case enableMapShowFavorites
case mapTileServer
case enableOverlayServer
case mapOverlayServer
@ -75,6 +76,7 @@ extension UserDefaults {
case mapReportingOptIn
case firstLaunch
case showDeviceOnboarding
case usageDataAndCrashReporting
case testIntEnum
}
@ -121,6 +123,9 @@ extension UserDefaults {
@UserDefault(.enableMapPointsOfInterest, defaultValue: false)
static var enableMapPointsOfInterest: Bool
@UserDefault(.enableMapShowFavorites, defaultValue: false)
static var enableMapShowFavorites: Bool
@UserDefault(.enableDetectionNotifications, defaultValue: false)
static var enableDetectionNotifications: Bool
@ -154,6 +159,8 @@ extension UserDefaults {
@UserDefault(.mapReportingOptIn, defaultValue: false)
static var mapReportingOptIn: Bool
@UserDefault(.usageDataAndCrashReporting, defaultValue: true)
static var usageDataAndCrashReporting: Bool
@UserDefault(.firstLaunch, defaultValue: true)
static var firstLaunch: Bool

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,86 @@
//
// URLHandler.swift
// Meshtastic
//
// Created by Benjamin Faershtein on 6/27/25.
//
import SwiftUI
import CoreData
import OSLog
import TipKit
import MeshtasticProtobufs
struct ContactURLHandler {
static var minimumContactVersion = "2.6.9"
static func handleContactUrl(url: URL, bleManager: BLEManager) {
let supportedVersion = UserDefaults.firmwareVersion == "0.0.0" ||
minimumContactVersion.compare(UserDefaults.firmwareVersion, options: .numeric) == .orderedAscending ||
minimumContactVersion.compare(UserDefaults.firmwareVersion, options: .numeric) == .orderedSame
if !supportedVersion {
let alertController = UIAlertController(
title: "Firmware Upgrade Required",
message: "In order to import contacts via a QR code you need firmware version 2.6.9 or greater.",
preferredStyle: .alert
)
alertController.addAction(UIAlertAction(
title: "Close",
style: .cancel,
handler: nil
))
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
rootViewController.present(alertController, animated: true)
}
Logger.services.debug("User Alerted that a firmware upgrade is required to import contacts.")
} else {
let components = url.absoluteString.components(separatedBy: "#")
if let contactData = components.last {
let decodedString = contactData.base64urlToBase64()
if let decodedData = Data(base64Encoded: decodedString) {
do {
let contact = try MeshtasticProtobufs.SharedContact(serializedBytes: decodedData)
let alertController = UIAlertController(
title: "Add Contact",
message: "Would you like to add \(contact.user.longName) as a contact?",
preferredStyle: .alert
)
alertController.addAction(UIAlertAction(
title: "Yes",
style: .default,
handler: { _ in
let success = bleManager.addContactFromURL(base64UrlString: contactData)
Logger.services.debug("Contact added from URL: \(success ? "success" : "failed")")
}
))
alertController.addAction(UIAlertAction(
title: "No",
style: .cancel,
handler: nil
))
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
rootViewController.present(alertController, animated: true)
}
Logger.services.debug("Contact data extracted from URL: \(contactData, privacy: .public)")
} catch {
Logger.services.error("Failed to parse contact data: \(error.localizedDescription, privacy: .public)")
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
let errorAlert = UIAlertController(
title: "Error",
message: "Could not process contact information. Invalid format.",
preferredStyle: .alert
)
errorAlert.addAction(UIAlertAction(title: "OK", style: .default))
rootViewController.present(errorAlert, animated: true)
}
}
}
}
}
}
}

View file

@ -0,0 +1,66 @@
//
// KeychainHelper.swift
// Meshtastic
//
// Copyright(c) Garth Vander Houwen 6/17/25.
//
import Foundation
import Security
class KeychainHelper {
static let standard = KeychainHelper()
private init() {}
func save(key: String, value: String, service: String = Bundle.main.bundleIdentifier!) -> OSStatus {
let data = value.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrSynchronizable as String: kCFBooleanTrue!,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
]
SecItemDelete(query as CFDictionary) // Delete existing item if any
let status = SecItemAdd(query as CFDictionary, nil)
return status
}
func read(key: String, service: String = Bundle.main.bundleIdentifier!) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: kCFBooleanTrue,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecAttrSynchronizable as String: kCFBooleanTrue!
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess {
if let data = item as? Data {
return String(data: data, encoding: .utf8)
}
}
return nil
}
func delete(key: String, service: String = Bundle.main.bundleIdentifier!) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrSynchronizable as String: kCFBooleanTrue!
]
let status = SecItemDelete(query as CFDictionary)
return status
}
}

View file

@ -70,8 +70,8 @@ class LocalNotificationManager {
if notification.critical {
content.sound = UNNotificationSound.defaultCritical
}
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: nil)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error {

View file

@ -123,15 +123,42 @@ import OSLog
} else {
locationsArray = [location]
}
UserDefaults.standard.set(location.coordinate.latitude, forKey: "lastKnownLatitude")
UserDefaults.standard.set(location.coordinate.longitude, forKey: "lastKnownLongitude")
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastKnownLocationTimestamp")
return true
}
static let DefaultLocation = CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090)
static var currentLocation: CLLocationCoordinate2D {
guard let location = shared.manager.location else {
if let location = shared.manager.location {
return location.coordinate
} else {
// Check authorization status
let status = shared.manager.authorizationStatus
switch status {
case .notDetermined:
Logger.services.info("📍 [App] Location permission not determined, requesting authorization")
shared.manager.requestWhenInUseAuthorization()
case .denied, .restricted:
Logger.services.warning("📍 [App] Location access denied or restricted. Please enable location services in Settings to get accurate positioning!")
shared.manager.requestWhenInUseAuthorization()
default:
break
}
// Fallback 1: Last known location from UserDefaults (if within 4 hours)
if let lat = UserDefaults.standard.object(forKey: "lastKnownLatitude") as? Double,
let lon = UserDefaults.standard.object(forKey: "lastKnownLongitude") as? Double,
let timestamp = UserDefaults.standard.object(forKey: "lastKnownLocationTimestamp") as? Double,
lat >= -90 && lat <= 90,
lon >= -180 && lon <= 180,
Date().timeIntervalSince1970 - timestamp <= 14_400 { // 4 hours in seconds
Logger.services.info("📍 [App] Falling back to last known location (age: \(Int(Date().timeIntervalSince1970 - timestamp)) seconds)")
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
}
// Fallback 2: Default location
Logger.services.warning("📍 [App] No Location and no last known location, something is really wrong. Teleporting user to Apple Park")
return DefaultLocation
}
return location.coordinate
}
static var satsInView: Int {

View file

@ -15,7 +15,7 @@ import OSLog
import ActivityKit
#endif
// Simple extension to consicely pass values through a has_XXX boolean check
// Simple extension to concisely pass values through a has_XXX boolean check
fileprivate extension Bool {
func then<T>(_ value: T) -> T? {
self ? value : nil
@ -185,9 +185,6 @@ func channelPacket (channel: Channel, fromNum: Int64, context: NSManagedObjectCo
mutableChannels.add(newChannel)
}
fetchedMyInfo[0].channels = mutableChannels.copy() as? NSOrderedSet
if newChannel.name?.lowercased() == "admin" {
fetchedMyInfo[0].adminIndex = newChannel.index
}
context.refresh(newChannel, mergeChanges: true)
do {
try context.save()
@ -292,14 +289,18 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje
newTelemetries.append(telemetry)
newNode.telemetries? = NSOrderedSet(array: newTelemetries)
}
newNode.firstHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard)))
newNode.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard)))
if nodeInfo.lastHeard > 0 {
newNode.firstHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard)))
newNode.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard)))
} else {
newNode.firstHeard = Date()
newNode.lastHeard = Date()
}
newNode.snr = nodeInfo.snr
if nodeInfo.hasUser {
let newUser = UserEntity(context: context)
newUser.userId = nodeInfo.user.id
newUser.userId = nodeInfo.num.toHex()
newUser.num = Int64(nodeInfo.num)
newUser.longName = nodeInfo.user.longName
newUser.shortName = nodeInfo.user.shortName
@ -317,10 +318,27 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje
newUser.pkiEncrypted = true
newUser.publicKey = nodeInfo.user.publicKey
}
/// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default
if nodeInfo.user.hasIsUnmessagable {
newUser.unmessagable = nodeInfo.user.isUnmessagable
} else {
let roles = [2, 4, 5, 6, 7, 10, 11]
let containsRole = roles.contains(Int(newUser.role))
if containsRole {
newUser.unmessagable = true
} else {
newUser.unmessagable = false
}}
newNode.user = newUser
} else if nodeInfo.num > Constants.minimumNodeNum {
let newUser = createUser(num: Int64(nodeInfo.num), context: context)
newNode.user = newUser
do {
let newUser = try createUser(num: Int64(nodeInfo.num), context: context)
newNode.user = newUser
} catch CoreDataError.invalidInput(let message) {
Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(nodeInfo.num, privacy: .public) Error: \(message, privacy: .public)")
} catch {
Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(nodeInfo.num, privacy: .public) Error: \(error.localizedDescription, privacy: .public)")
}
}
if (nodeInfo.position.longitudeI != 0 && nodeInfo.position.latitudeI != 0) && (nodeInfo.position.latitudeI != 373346000 && nodeInfo.position.longitudeI != -1220090000) {
@ -380,26 +398,43 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje
fetchedNode[0].user?.pkiEncrypted = true
fetchedNode[0].user?.publicKey = nodeInfo.user.publicKey
}
fetchedNode[0].user!.userId = nodeInfo.user.id
fetchedNode[0].user!.num = Int64(nodeInfo.num)
fetchedNode[0].user!.numString = String(nodeInfo.num)
fetchedNode[0].user!.longName = nodeInfo.user.longName
fetchedNode[0].user!.shortName = nodeInfo.user.shortName
fetchedNode[0].user!.isLicensed = nodeInfo.user.isLicensed
fetchedNode[0].user!.role = Int32(nodeInfo.user.role.rawValue)
fetchedNode[0].user!.hwModel = String(describing: nodeInfo.user.hwModel).uppercased()
fetchedNode[0].user!.hwModelId = Int32(nodeInfo.user.hwModel.rawValue)
fetchedNode[0].user?.userId = nodeInfo.num.toHex()
fetchedNode[0].user?.num = Int64(nodeInfo.num)
fetchedNode[0].user?.numString = String(nodeInfo.num)
fetchedNode[0].user?.longName = nodeInfo.user.longName
fetchedNode[0].user?.shortName = nodeInfo.user.shortName
fetchedNode[0].user?.isLicensed = nodeInfo.user.isLicensed
fetchedNode[0].user?.role = Int32(nodeInfo.user.role.rawValue)
fetchedNode[0].user?.hwModel = String(describing: nodeInfo.user.hwModel).uppercased()
fetchedNode[0].user?.hwModelId = Int32(nodeInfo.user.hwModel.rawValue)
/// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default
if nodeInfo.user.hasIsUnmessagable {
fetchedNode[0].user?.unmessagable = nodeInfo.user.isUnmessagable
} else {
let roles = [-1, 2, 4, 5, 6, 7, 10, 11]
let containsRole = roles.contains(Int(fetchedNode[0].user?.role ?? -1))
if containsRole {
fetchedNode[0].user?.unmessagable = true
} else {
fetchedNode[0].user?.unmessagable = false
}
}
Task {
Api().loadDeviceHardwareData { (hw) in
let dh = hw.first(where: { $0.hwModel == fetchedNode[0].user!.hwModelId })
fetchedNode[0].user!.hwDisplayName = dh?.displayName
fetchedNode[0].user?.hwDisplayName = dh?.displayName
}
}
} else {
if fetchedNode[0].user == nil && nodeInfo.num > Constants.minimumNodeNum {
let newUser = createUser(num: Int64(nodeInfo.num), context: context)
fetchedNode[0].user = newUser
do {
let newUser = try createUser(num: Int64(nodeInfo.num), context: context)
fetchedNode[0].user = newUser
} catch CoreDataError.invalidInput(let message) {
Logger.data.error("Error Creating a new Core Data UserEntity on an existing node (Invalid Input) from node number: \(nodeInfo.num, privacy: .public) Error: \(message, privacy: .public)")
} catch {
Logger.data.error("Error Creating a new Core Data UserEntity on an existing node from node number: \(nodeInfo.num, privacy: .public) Error: \(error.localizedDescription, privacy: .public)")
}
}
}
@ -469,9 +504,6 @@ func adminAppPacket (packet: MeshPacket, context: NSManagedObjectContext) {
if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getCannedMessageModuleMessagesResponse(adminMessage.getCannedMessageModuleMessagesResponse) {
if let cmmc = try? CannedMessageModuleConfig(serializedBytes: packet.decoded.payload) {
if !cmmc.messages.isEmpty {
let logString = String.localizedStringWithFormat("Canned Messages Messages Received For: %@".localized, packet.from.toHex())
Logger.mesh.info("🥫 \(logString, privacy: .public)")
@ -485,10 +517,11 @@ func adminAppPacket (packet: MeshPacket, context: NSManagedObjectContext) {
.replacingOccurrences(of: "11: ", with: "")
.replacingOccurrences(of: "\"", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "\n").first ?? ""
fetchedNode[0].cannedMessageConfig?.messages = messages
do {
try context.save()
Logger.data.info("💾 Updated Canned Messages Messages For: \(fetchedNode[0].num.toHex(), privacy: .public)")
Logger.data.info("💾 Updated Canned Messages Messages For: \(fetchedNode.first?.num.toHex() ?? "Unknown".localized, privacy: .public)")
} catch {
context.rollback()
let nsError = error as NSError
@ -498,7 +531,6 @@ func adminAppPacket (packet: MeshPacket, context: NSManagedObjectContext) {
} catch {
Logger.data.error("💥 Error Deserializing ADMIN_APP packet.")
}
}
}
} else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getChannelResponse(adminMessage.getChannelResponse) {
channelPacket(channel: adminMessage.getChannelResponse, fromNum: Int64(packet.from), context: context)
@ -688,7 +720,7 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage
if let telemetryMessage = try? Telemetry(serializedBytes: packet.decoded.payload) {
let logString = String.localizedStringWithFormat("Telemetry received for: %@".localized, String(packet.from))
Logger.mesh.info("📈 \(logString, privacy: .public)")
if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) && telemetryMessage.variant != Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) {
if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) && telemetryMessage.variant != Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) {
/// Other unhandled telemetry packets
return
}
@ -909,7 +941,14 @@ func textMessageAppPacket(
// For S&F broadcast messages, treat as a channel message (not a DM)
newMessage.toUser = nil
} else {
newMessage.toUser = createUser(num: Int64(truncatingIfNeeded: packet.to), context: context)
do {
let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.to), context: context)
newMessage.toUser = newUser
} catch CoreDataError.invalidInput(let message) {
Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.to, privacy: .public) Error: \(message, privacy: .public)")
} catch {
Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.to, privacy: .public) Error: \(error.localizedDescription, privacy: .public)")
}
}
}
if fetchedUsers.first(where: { $0.num == packet.from }) != nil {
@ -939,7 +978,14 @@ func textMessageAppPacket(
}
} else {
/// Make a new from user if they are unknown
newMessage.fromUser = createUser(num: Int64(truncatingIfNeeded: packet.from), context: context)
do {
let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context)
newMessage.fromUser = newUser
} catch CoreDataError.invalidInput(let message) {
Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)")
} catch {
Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)")
}
}
if packet.rxTime > 0 {
newMessage.fromUser?.userNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime)))
@ -956,79 +1002,79 @@ func textMessageAppPacket(
try context.save()
Logger.data.info("💾 Saved a new message for \(newMessage.messageId, privacy: .public)")
messageSaved = true
if messageSaved {
if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications {
return
}
if newMessage.fromUser != nil && newMessage.toUser != nil {
// Set Unread Message Indicators
if packet.to == connectedNode {
appState.unreadDirectMessages = newMessage.toUser?.unreadMessages ?? 0
}
if !(newMessage.fromUser?.mute ?? false) {
// Create an iOS Notification for the received DM message
let manager = LocalNotificationManager()
manager.notifications = [
Notification(
id: ("notification.id.\(newMessage.messageId)"),
title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)",
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
content: messageText!,
target: "messages",
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)",
messageId: newMessage.messageId,
channel: newMessage.channel,
userNum: Int64(packet.from),
critical: critical
)
]
manager.schedule()
Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)")
}
} else if newMessage.fromUser != nil && newMessage.toUser == nil {
let fetchMyInfoRequest = MyInfoEntity.fetchRequest()
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedNode))
do {
let fetchedMyInfo = try context.fetch(fetchMyInfoRequest)
if !fetchedMyInfo.isEmpty {
appState.unreadChannelMessages = fetchedMyInfo[0].unreadMessages
for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] {
if channel.index == newMessage.channel {
context.refresh(channel, mergeChanges: true)
}
if channel.index == newMessage.channel && !channel.mute && UserDefaults.channelMessageNotifications {
// Create an iOS Notification for the received channel message
let manager = LocalNotificationManager()
manager.notifications = [
Notification(
id: ("notification.id.\(newMessage.messageId)"),
title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)",
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
content: messageText!,
target: "messages",
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)",
messageId: newMessage.messageId,
channel: newMessage.channel,
userNum: Int64(newMessage.fromUser?.userId ?? "0"),
critical: critical
)
]
manager.schedule()
Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)")
}
}
}
} catch {
// Handle error
}
}
}
} catch {
context.rollback()
let nsError = error as NSError
Logger.data.error("Failed to save new MessageEntity \(nsError, privacy: .public)")
}
// Send notifications if the message saved properly to core data
if messageSaved {
if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications {
return
}
if newMessage.fromUser != nil && newMessage.toUser != nil {
// Set Unread Message Indicators
if packet.to == connectedNode {
appState.unreadDirectMessages = newMessage.toUser?.unreadMessages ?? 0
}
if !(newMessage.fromUser?.mute ?? false) {
// Create an iOS Notification for the received DM message
let manager = LocalNotificationManager()
manager.notifications = [
Notification(
id: ("notification.id.\(newMessage.messageId)"),
title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)",
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
content: messageText!,
target: "messages",
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)",
messageId: newMessage.messageId,
channel: newMessage.channel,
userNum: Int64(packet.from),
critical: critical
)
]
manager.schedule()
Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)")
}
} else if newMessage.fromUser != nil && newMessage.toUser == nil {
let fetchMyInfoRequest = MyInfoEntity.fetchRequest()
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedNode))
do {
let fetchedMyInfo = try context.fetch(fetchMyInfoRequest)
if !fetchedMyInfo.isEmpty {
appState.unreadChannelMessages = fetchedMyInfo[0].unreadMessages
for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] {
if channel.index == newMessage.channel {
context.refresh(channel, mergeChanges: true)
}
if channel.index == newMessage.channel && !channel.mute && UserDefaults.channelMessageNotifications {
// Create an iOS Notification for the received channel message
let manager = LocalNotificationManager()
manager.notifications = [
Notification(
id: ("notification.id.\(newMessage.messageId)"),
title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)",
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
content: messageText!,
target: "messages",
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)",
messageId: newMessage.messageId,
channel: newMessage.channel,
userNum: Int64(newMessage.fromUser?.userId ?? "0"),
critical: critical
)
]
manager.schedule()
Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)")
}
}
}
} catch {
// Handle error
}
}
}
} catch {
Logger.data.error("Fetch Message To and From Users Error")
}
@ -1040,17 +1086,29 @@ func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) {
let logString = String.localizedStringWithFormat("Waypoint Packet received from node: %@".localized, String(packet.from))
Logger.mesh.info("📍 \(logString, privacy: .public)")
let fetchWaypointRequest = WaypointEntity.fetchRequest()
fetchWaypointRequest.predicate = NSPredicate(format: "id == %lld", Int64(packet.id))
do {
if let waypointMessage = try? Waypoint(serializedBytes: packet.decoded.payload) {
let fetchedWaypoint = try context.fetch(fetchWaypointRequest)
if fetchedWaypoint.isEmpty {
let waypoint = WaypointEntity(context: context)
// Fetch waypoint by waypointMessage.id, not packet.id
let fetchWaypointRequest = WaypointEntity.fetchRequest()
fetchWaypointRequest.predicate = NSPredicate(format: "id == %lld", Int64(waypointMessage.id))
waypoint.id = Int64(packet.id)
let fetchedWaypoint = try context.fetch(fetchWaypointRequest)
// Fetch the node info to get the short name
var nodeShortName: String = "?"
let fetchNodeRequest = NodeInfoEntity.fetchRequest()
fetchNodeRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from))
do {
let fetchedNode = try context.fetch(fetchNodeRequest)
if let node = fetchedNode.first, let user = node.user {
nodeShortName = user.shortName ?? node.user?.userId ?? String(packet.from.toHex())
}
} catch {
Logger.data.error("Failed to fetch NodeInfoEntity for node \(packet.from.toHex(), privacy: .public): \(error)")
}
if fetchedWaypoint.isEmpty {
// Create a new waypoint
let waypoint = WaypointEntity(context: context)
waypoint.id = Int64(waypointMessage.id) // Use waypointMessage.id
waypoint.name = waypointMessage.name
waypoint.longDescription = waypointMessage.description_p
waypoint.latitudeI = waypointMessage.latitudeI
@ -1073,7 +1131,7 @@ func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) {
manager.notifications = [
Notification(
id: ("notification.id.\(waypoint.id)"),
title: "New Waypoint Received",
title: "New Waypoint From \(nodeShortName)",
subtitle: "\(icon) \(waypoint.name ?? "Dropped Pin")",
content: "\(waypoint.longDescription ?? "\(latitude), \(longitude)")",
target: "map",
@ -1088,26 +1146,42 @@ func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) {
Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)")
}
} else {
fetchedWaypoint[0].id = Int64(packet.id)
fetchedWaypoint[0].name = waypointMessage.name
fetchedWaypoint[0].longDescription = waypointMessage.description_p
fetchedWaypoint[0].latitudeI = waypointMessage.latitudeI
fetchedWaypoint[0].longitudeI = waypointMessage.longitudeI
fetchedWaypoint[0].icon = Int64(waypointMessage.icon)
fetchedWaypoint[0].locked = Int64(waypointMessage.lockedTo)
if waypointMessage.expire >= 1 {
fetchedWaypoint[0].expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire)))
} else {
fetchedWaypoint[0].expire = nil
}
fetchedWaypoint[0].lastUpdated = Date()
do {
try context.save()
Logger.data.info("💾 Updated Node Waypoint App Packet For: \(fetchedWaypoint[0].id, privacy: .public)")
} catch {
context.rollback()
let nsError = error as NSError
Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)")
// Update existing waypoint
let existingWaypoint = fetchedWaypoint[0]
if existingWaypoint.locked == 0 || existingWaypoint.locked == packet.from {
let currentTime = Int64(Date().timeIntervalSince1970)
if waypointMessage.expire > 0 && waypointMessage.expire <= currentTime {
context.delete(existingWaypoint)
do {
try context.save()
Logger.data.info("💾 Deleted a waypoint")
} catch {
context.rollback()
let nsError = error as NSError
Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)")
}
} else {
existingWaypoint.name = waypointMessage.name
existingWaypoint.longDescription = waypointMessage.description_p
existingWaypoint.latitudeI = waypointMessage.latitudeI
existingWaypoint.longitudeI = waypointMessage.longitudeI
existingWaypoint.icon = Int64(waypointMessage.icon)
existingWaypoint.locked = Int64(waypointMessage.lockedTo)
if waypointMessage.expire >= 1 {
existingWaypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire)))
} else {
existingWaypoint.expire = nil
}
existingWaypoint.lastUpdated = Date()
do {
try context.save()
Logger.data.info("💾 Updated Node Waypoint App Packet For: \(existingWaypoint.id, privacy: .public)")
} catch {
context.rollback()
let nsError = error as NSError
Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)")
}
}
}
}
}

View file

@ -41,31 +41,29 @@ class MqttClientProxyManager {
if let host = host {
let port = defaultServerPort
let username = node.mqttConfig?.username
let password = node.mqttConfig?.password
let root = node.mqttConfig?.root?.count ?? 0 > 0 ? node.mqttConfig?.root : "msh"
let prefix = root!
topic = prefix + "/2/e" + "/#"
// Require opt in to map report terms to connect
if node.mqttConfig?.mapReportingEnabled ?? false && UserDefaults.mapReportingOptIn || !(node.mqttConfig?.mapReportingEnabled ?? false) {
connect(host: host, port: port, useSsl: useSsl, username: username, password: password, topic: topic)
connect(host: host, port: port, useSsl: useSsl, topic: topic, node: node)
} else {
delegate?.onMqttError(message: "MQTT Map Reporting Terms need to be accepted.")
}
}
}
func connect(host: String, port: Int, useSsl: Bool, username: String?, password: String?, topic: String?) {
func connect(host: String, port: Int, useSsl: Bool, topic: String?, node: NodeInfoEntity) {
guard !host.isEmpty else {
delegate?.onMqttDisconnected()
return
}
let clientId = "MeshtasticAppleMqttProxy-" + String(ProcessInfo().processIdentifier)
let clientId = "MeshtasticAppleMqttProxy-" + (node.user?.userId ?? String(ProcessInfo().processIdentifier))
mqttClientProxy = CocoaMQTT(clientID: clientId, host: host, port: UInt16(port))
if let mqttClient = mqttClientProxy {
mqttClient.enableSSL = useSsl
mqttClient.allowUntrustCACertificate = true
mqttClient.username = username
mqttClient.password = password
mqttClient.username = node.mqttConfig?.username
mqttClient.password = node.mqttConfig?.password
mqttClient.keepAlive = 60
mqttClient.cleanSession = true
if debugLog {

View file

@ -2,12 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.usernotifications.critical-alerts</key>
<true/>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:meshtastic.org/e/*</string>
<string>applinks:meshtastic.org/v/*</string>
</array>
<key>com.apple.developer.carplay-communication</key>
<true/>
<key>com.apple.developer.usernotifications.critical-alerts</key>
<true/>
<key>com.apple.developer.weatherkit</key>
<true/>
<key>com.apple.security.app-sandbox</key>
@ -20,7 +23,9 @@
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.developer.carplay-communication</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)gvh.MeshtasticClient</string>
</array>
</dict>
</plist>

View file

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>MeshtasticDataModelV 50.xcdatamodel</string>
<string>MeshtasticDataModelV 53.xcdatamodel</string>
</dict>
</plist>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24D81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788" systemVersion="24D81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AmbientLightingConfigEntity" representedClassName="AmbientLightingConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="blue" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>

View file

@ -0,0 +1,505 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788" systemVersion="24D81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AmbientLightingConfigEntity" representedClassName="AmbientLightingConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="blue" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="green" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ledState" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="red" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="ambientLightingConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="ambientLightingConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="BluetoothConfigEntity" representedClassName="BluetoothConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="deviceLoggingEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fixedPin" optional="YES" attributeType="Integer 32" defaultValueString="123456" usesScalarValueType="YES"/>
<attribute name="mode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="bluetoothConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="bluetoothConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="CannedMessageConfigEntity" representedClassName="CannedMessageConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventCcw" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventCw" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventPress" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinA" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinB" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinPress" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="messages" optional="YES" attributeType="String" minValueString="0" maxValueString="198"/>
<attribute name="rotary1Enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="sendBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updown1Enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="cannedMessagesConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="cannedMessageConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="ChannelEntity" representedClassName="ChannelEntity" syncable="YES" codeGenerationType="class">
<attribute name="downlinkEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="index" attributeType="Integer 32" minValueString="0" maxValueString="13" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="mute" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="positionPrecision" optional="YES" attributeType="Integer 32" defaultValueString="32" usesScalarValueType="YES"/>
<attribute name="psk" optional="YES" attributeType="Binary"/>
<attribute name="role" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="uplinkEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="myInfoChannel" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MyInfoEntity" inverseName="channels" inverseEntity="MyInfoEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="index"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="DetectionSensorConfigEntity" representedClassName="DetectionSensorConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="minimumBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="monitorPin" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="sendBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="stateBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="triggerType" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="usePullup" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="detectionSensorConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="detectionSensorConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DeviceConfigEntity" representedClassName="DeviceConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="buttonGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="buzzerGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="debugLogEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="disableTripleClick" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="doubleTapAsButtonPress" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="isManaged" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="ledHeartbeatEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="nodeInfoBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rebroadcastMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="role" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="serialEnabled" optional="YES" attributeType="Boolean" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="tripleClickAsAdHocPing" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="tzdef" optional="YES" attributeType="String"/>
<relationship name="deviceConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="deviceConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DeviceMetadataEntity" representedClassName="DeviceMetadataEntity" syncable="YES" codeGenerationType="class">
<attribute name="canShutdown" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="deviceStateVersion" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="excludedModules" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="firmwareVersion" optional="YES" attributeType="String"/>
<attribute name="hasBluetooth" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hasEthernet" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hasWifi" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hwModel" optional="YES" attributeType="String"/>
<attribute name="positionFlags" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="role" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="metadataNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="metadata" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DisplayConfigEntity" representedClassName="DisplayConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="compassNorthTop" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="displayMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="flipScreen" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="gpsFormat" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="headingBold" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="oledType" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="screenCarouselInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="screenOnSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="units" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wakeOnTapOrMotion" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="displayConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="displayConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="ExternalNotificationConfigEntity" representedClassName="ExternalNotificationConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="active" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBellBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBellVibra" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessage" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessageBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessageVibra" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="nagTimeout" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="output" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputBuzzer" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputMilliseconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputVibra" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="useI2SAsBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="usePWM" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<relationship name="externalNotificationConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="externalNotificationConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="LocationEntity" representedClassName="LocationEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="heading" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="latitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="speed" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="routeLocation" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RouteEntity" inverseName="locations" inverseEntity="RouteEntity"/>
</entity>
<entity name="LoRaConfigEntity" representedClassName="LoRaConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="bandwidth" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="channelNum" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="codingRate" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="frequencyOffset" optional="YES" attributeType="Float" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="hopLimit" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ignoreMqtt" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="modemPreset" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="okToMqtt" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="overrideDutyCycle" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="overrideFrequency" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="regionCode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="spreadFactor" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sx126xRxBoostedGain" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="txEnabled" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="txPower" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="usePreset" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<relationship name="loRaConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="loRaConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="MessageEntity" representedClassName="MessageEntity" syncable="YES" codeGenerationType="class">
<attribute name="ackError" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ackSNR" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="ackTimestamp" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="admin" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="adminDescription" optional="YES" attributeType="String"/>
<attribute name="channel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isEmoji" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="messageId" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="messagePayload" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="messagePayloadMarkdown" optional="YES" attributeType="String"/>
<attribute name="messageTimestamp" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pkiEncrypted" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="portNum" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="read" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="realACK" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="receivedACK" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="replyID" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="fromUser" optional="YES" maxCount="1" deletionRule="Nullify" ordered="YES" destinationEntity="UserEntity" inverseName="sentMessages" inverseEntity="UserEntity"/>
<relationship name="toUser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="receivedMessages" inverseEntity="UserEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="messageId"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="MQTTConfigEntity" representedClassName="MQTTConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="address" optional="YES" attributeType="String"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="encryptionEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="jsonEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="mapPositionPrecision" optional="YES" attributeType="Integer 32" defaultValueString="13" usesScalarValueType="YES"/>
<attribute name="mapPublishIntervalSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="mapReportingEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="password" optional="YES" attributeType="String" maxValueString="30"/>
<attribute name="proxyToClientEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="root" optional="YES" attributeType="String" defaultValueString="msh"/>
<attribute name="tlsEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="username" optional="YES" attributeType="String" maxValueString="30"/>
<relationship name="mqttConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="mqttConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="MyInfoEntity" representedClassName="MyInfoEntity" syncable="YES" codeGenerationType="class">
<attribute name="adminIndex" optional="YES" attributeType="Integer 32" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="bleName" optional="YES" attributeType="String"/>
<attribute name="deviceId" optional="YES" attributeType="Binary"/>
<attribute name="minAppVersion" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="myNodeNum" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="peripheralId" optional="YES" attributeType="String"/>
<attribute name="rebootCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="registered" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="channels" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="ChannelEntity" inverseName="myInfoChannel" inverseEntity="ChannelEntity"/>
<relationship name="myInfoNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="myInfo" inverseEntity="NodeInfoEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="myNodeNum"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="NetworkConfigEntity" representedClassName="NetworkConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="dns" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="enabledProtocols" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ethEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="gateway" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ip" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ntpServer" optional="YES" attributeType="String"/>
<attribute name="subnet" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifiEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="wifiMode" optional="YES" attributeType="Integer 32" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="wifiPsk" optional="YES" attributeType="String" minValueString="0" maxValueString="60"/>
<attribute name="wifiSsid" optional="YES" attributeType="String" minValueString="0" maxValueString="30"/>
<relationship name="networkConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="networkConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="NodeInfoEntity" representedClassName="NodeInfoEntity" syncable="YES" codeGenerationType="class">
<attribute name="bleName" optional="YES" attributeType="String"/>
<attribute name="channel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="favorite" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="firstHeard" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="hopsAway" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ignored" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastHeard" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="num" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="peripheralId" optional="YES" attributeType="String"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sessionExpiration" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sessionPasskey" optional="YES" attributeType="Binary"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="viaMqtt" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="ambientLightingConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="AmbientLightingConfigEntity" inverseName="ambientLightingConfigNode" inverseEntity="AmbientLightingConfigEntity"/>
<relationship name="bluetoothConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="BluetoothConfigEntity" inverseName="bluetoothConfigNode" inverseEntity="BluetoothConfigEntity"/>
<relationship name="cannedMessageConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="CannedMessageConfigEntity" inverseName="cannedMessagesConfigNode" inverseEntity="CannedMessageConfigEntity"/>
<relationship name="detectionSensorConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DetectionSensorConfigEntity" inverseName="detectionSensorConfigNode" inverseEntity="DetectionSensorConfigEntity"/>
<relationship name="deviceConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DeviceConfigEntity" inverseName="deviceConfigNode" inverseEntity="DeviceConfigEntity"/>
<relationship name="displayConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DisplayConfigEntity" inverseName="displayConfigNode" inverseEntity="DisplayConfigEntity"/>
<relationship name="externalNotificationConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ExternalNotificationConfigEntity" inverseName="externalNotificationConfigNode" inverseEntity="ExternalNotificationConfigEntity"/>
<relationship name="loRaConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LoRaConfigEntity" inverseName="loRaConfigNode" inverseEntity="LoRaConfigEntity"/>
<relationship name="metadata" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="DeviceMetadataEntity" inverseName="metadataNode" inverseEntity="DeviceMetadataEntity"/>
<relationship name="mqttConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MQTTConfigEntity" inverseName="mqttConfigNode" inverseEntity="MQTTConfigEntity"/>
<relationship name="myInfo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MyInfoEntity" inverseName="myInfoNode" inverseEntity="MyInfoEntity"/>
<relationship name="networkConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NetworkConfigEntity" inverseName="networkConfigNode" inverseEntity="NetworkConfigEntity"/>
<relationship name="pax" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="PaxCounterEntity" inverseName="paxNode" inverseEntity="PaxCounterEntity"/>
<relationship name="paxCounterConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PaxCounterConfigEntity" inverseName="paxCounterConfigNode" inverseEntity="PaxCounterConfigEntity"/>
<relationship name="positionConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PositionConfigEntity" inverseName="positionConfigNode" inverseEntity="PositionConfigEntity"/>
<relationship name="positions" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="PositionEntity" inverseName="nodePosition" inverseEntity="PositionEntity"/>
<relationship name="powerConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PowerConfigEntity" inverseName="powerConfigNode" inverseEntity="PowerConfigEntity"/>
<relationship name="rangeTestConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RangeTestConfigEntity" inverseName="rangeTestConfigNode" inverseEntity="RangeTestConfigEntity"/>
<relationship name="rtttlConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RTTTLConfigEntity" inverseName="rtttlConfigNode" inverseEntity="RTTTLConfigEntity"/>
<relationship name="securityConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SecurityConfigEntity" inverseName="securityConfigNode" inverseEntity="SecurityConfigEntity"/>
<relationship name="serialConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SerialConfigEntity" inverseName="serialConfigNode" inverseEntity="SerialConfigEntity"/>
<relationship name="storeForwardConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreForwardConfigEntity" inverseName="storeForwardConfigNode" inverseEntity="StoreForwardConfigEntity"/>
<relationship name="telemetries" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="TelemetryEntity" inverseName="nodeTelemetry" inverseEntity="TelemetryEntity"/>
<relationship name="telemetryConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="TelemetryConfigEntity" inverseName="telemetryConfigNode" inverseEntity="TelemetryConfigEntity"/>
<relationship name="traceRoutes" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="TraceRouteEntity" inverseName="node" inverseEntity="TraceRouteEntity"/>
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="userNode" inverseEntity="UserEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="num"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="PaxCounterConfigEntity" representedClassName="PaxCounterConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="bleThreshold" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifiThreshold" optional="YES" attributeType="Integer 32" defaultValueString="-80" usesScalarValueType="YES"/>
<relationship name="paxCounterConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="paxCounterConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PaxCounterEntity" representedClassName="PaxCounterEntity" syncable="YES" codeGenerationType="class">
<attribute name="ble" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uptime" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="paxNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="pax" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PositionConfigEntity" representedClassName="PositionConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="broadcastSmartMinimumDistance" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="broadcastSmartMinimumIntervalSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="deviceGpsEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fixedPosition" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="gpsAttemptTime" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsEnGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="positionBroadcastSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="positionFlags" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rxGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="smartPositionEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="txGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="positionConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="positionConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PositionEntity" representedClassName="PositionEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="heading" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="latest" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="latitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="precisionBits" optional="YES" attributeType="Integer 32" defaultValueString="32" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="satsInView" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="seqNo" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="speed" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="nodePosition" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="positions" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PowerConfigEntity" representedClassName="PowerConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="adcMultiplierOverride" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="deviceBatteryInaAddress" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isPowerSaving" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="lsSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="minWakeSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="onBatteryShutdownAfterSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="waitBluetoothSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="powerConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="powerConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="RangeTestConfigEntity" representedClassName="RangeTestConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="save" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="sender" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<relationship name="rangeTestConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="rangeTestConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="RouteEntity" representedClassName="RouteEntity" syncable="YES" codeGenerationType="class">
<attribute name="color" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="distance" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="elevationGain" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="endDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<relationship name="locations" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="LocationEntity" inverseName="routeLocation" inverseEntity="LocationEntity"/>
</entity>
<entity name="RTTTLConfigEntity" representedClassName="RTTTLConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="ringtone" optional="YES" attributeType="String" maxValueString="228" defaultValueString=""/>
<relationship name="rtttlConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="rtttlConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="SecurityConfigEntity" representedClassName="SecurityConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="adminChannelEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="adminKey" optional="YES" attributeType="Binary"/>
<attribute name="adminKey2" optional="YES" attributeType="Binary"/>
<attribute name="adminKey3" optional="YES" attributeType="Binary"/>
<attribute name="bluetoothLoggingEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="debugLogApiEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isManaged" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="privateKey" optional="YES" attributeType="Binary"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="serialEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="securityConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="securityConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="SerialConfigEntity" representedClassName="SerialConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="baudRate" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="echo" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="overrideConsoleSerialPort" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="rxd" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="timeout" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="txd" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="serialConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="serialConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="StoreForwardConfigEntity" representedClassName="StoreForwardConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="heartbeat" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="historyReturnMax" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="historyReturnWindow" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isRouter" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastHeartbeat" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="lastRequest" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="records" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="storeForwardConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="storeForwardConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TelemetryConfigEntity" representedClassName="TelemetryConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="deviceUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="environmentDisplayFahrenheit" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentMeasurementEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentScreenEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="powerMeasurementEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="powerScreenEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="powerUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="telemetryConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetryConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TelemetryEntity" representedClassName="TelemetryEntity" syncable="YES">
<attribute name="airUtilTx" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="barometricPressure" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="batteryLevel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="channelUtilization" optional="YES" attributeType="Float" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="gasResistance" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="iaq" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="irLux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="lux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="metricsType" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numOnlineNodes" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsRx" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsRxBad" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsTx" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numRxDupe" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numTotalNodes" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numTxRelay" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numTxRelayCanceled" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="powerCh1Current" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh1Voltage" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh2Current" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh2Voltage" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh3Current" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh3Voltage" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="radiation" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="rainfall1H" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="rainfall24H" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="relativeHumidity" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="soilMoisture" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="soilTemperature" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="temperature" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uptimeSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="uvLux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="voltage" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="weight" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="whiteLux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windDirection" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="windGust" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windLull" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windSpeed" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="nodeTelemetry" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetries" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TraceRouteEntity" representedClassName="TraceRouteEntity" syncable="YES" codeGenerationType="class">
<attribute name="hasPositions" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="hopsBack" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="hopsTowards" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="response" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="routeBackText" optional="YES" attributeType="String"/>
<attribute name="routeText" optional="YES" attributeType="String"/>
<attribute name="sent" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="hops" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="TraceRouteHopEntity" inverseName="traceRoute" inverseEntity="TraceRouteHopEntity"/>
<relationship name="node" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="traceRoutes" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TraceRouteHopEntity" representedClassName="TraceRouteHopEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="back" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="latitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="num" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="time" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="traceRoute" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="TraceRouteEntity" inverseName="hops" inverseEntity="TraceRouteEntity"/>
</entity>
<entity name="UserEntity" representedClassName="UserEntity" syncable="YES" codeGenerationType="class">
<attribute name="hwDisplayName" optional="YES" attributeType="String"/>
<attribute name="hwModel" attributeType="String"/>
<attribute name="hwModelId" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isLicensed" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="keyMatch" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="lastMessage" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="longName" attributeType="String"/>
<attribute name="mute" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="newPublicKey" optional="YES" attributeType="Binary"/>
<attribute name="num" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numString" optional="YES" attributeType="String"/>
<attribute name="pkiEncrypted" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="role" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="shortName" attributeType="String"/>
<attribute name="unmessagable" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="userId" attributeType="String"/>
<relationship name="receivedMessages" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="MessageEntity" inverseName="toUser" inverseEntity="MessageEntity"/>
<relationship name="sentMessages" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="MessageEntity" inverseName="fromUser" inverseEntity="MessageEntity"/>
<relationship name="userNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="user" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="WaypointEntity" representedClassName="WaypointEntity" syncable="YES" codeGenerationType="class">
<attribute name="created" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="expire" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="icon" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="latitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="locked" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="longDescription" optional="YES" attributeType="String" maxValueString="100"/>
<attribute name="longitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String" minValueString="1" maxValueString="30"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
</model>

View file

@ -0,0 +1,506 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788" systemVersion="24D81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AmbientLightingConfigEntity" representedClassName="AmbientLightingConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="blue" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="green" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ledState" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="red" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="ambientLightingConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="ambientLightingConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="BluetoothConfigEntity" representedClassName="BluetoothConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="deviceLoggingEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fixedPin" optional="YES" attributeType="Integer 32" defaultValueString="123456" usesScalarValueType="YES"/>
<attribute name="mode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="bluetoothConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="bluetoothConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="CannedMessageConfigEntity" representedClassName="CannedMessageConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventCcw" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventCw" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventPress" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinA" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinB" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinPress" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="messages" optional="YES" attributeType="String" minValueString="0" maxValueString="198"/>
<attribute name="rotary1Enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="sendBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updown1Enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="cannedMessagesConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="cannedMessageConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="ChannelEntity" representedClassName="ChannelEntity" syncable="YES" codeGenerationType="class">
<attribute name="downlinkEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="index" attributeType="Integer 32" minValueString="0" maxValueString="13" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="mute" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="positionPrecision" optional="YES" attributeType="Integer 32" defaultValueString="32" usesScalarValueType="YES"/>
<attribute name="psk" optional="YES" attributeType="Binary"/>
<attribute name="role" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="uplinkEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="myInfoChannel" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MyInfoEntity" inverseName="channels" inverseEntity="MyInfoEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="index"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="DetectionSensorConfigEntity" representedClassName="DetectionSensorConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="minimumBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="monitorPin" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="sendBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="stateBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="triggerType" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="usePullup" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="detectionSensorConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="detectionSensorConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DeviceConfigEntity" representedClassName="DeviceConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="buttonGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="buzzerGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="debugLogEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="disableTripleClick" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="doubleTapAsButtonPress" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="isManaged" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="ledHeartbeatEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="nodeInfoBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rebroadcastMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="role" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="serialEnabled" optional="YES" attributeType="Boolean" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="tripleClickAsAdHocPing" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="tzdef" optional="YES" attributeType="String"/>
<relationship name="deviceConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="deviceConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DeviceMetadataEntity" representedClassName="DeviceMetadataEntity" syncable="YES" codeGenerationType="class">
<attribute name="canShutdown" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="deviceStateVersion" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="excludedModules" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="firmwareVersion" optional="YES" attributeType="String"/>
<attribute name="hasBluetooth" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hasEthernet" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hasWifi" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hwModel" optional="YES" attributeType="String"/>
<attribute name="positionFlags" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="role" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="metadataNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="metadata" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DisplayConfigEntity" representedClassName="DisplayConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="compassNorthTop" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="displayMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="flipScreen" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="gpsFormat" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="headingBold" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="oledType" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="screenCarouselInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="screenOnSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="units" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wakeOnTapOrMotion" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="displayConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="displayConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="ExternalNotificationConfigEntity" representedClassName="ExternalNotificationConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="active" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBellBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBellVibra" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessage" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessageBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessageVibra" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="nagTimeout" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="output" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputBuzzer" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputMilliseconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputVibra" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="useI2SAsBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="usePWM" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<relationship name="externalNotificationConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="externalNotificationConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="LocationEntity" representedClassName="LocationEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="heading" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="latitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="speed" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="routeLocation" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RouteEntity" inverseName="locations" inverseEntity="RouteEntity"/>
</entity>
<entity name="LoRaConfigEntity" representedClassName="LoRaConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="bandwidth" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="channelNum" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="codingRate" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="frequencyOffset" optional="YES" attributeType="Float" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="hopLimit" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ignoreMqtt" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="modemPreset" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="okToMqtt" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="overrideDutyCycle" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="overrideFrequency" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="regionCode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="spreadFactor" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sx126xRxBoostedGain" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="txEnabled" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="txPower" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="usePreset" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<relationship name="loRaConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="loRaConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="MessageEntity" representedClassName="MessageEntity" syncable="YES" codeGenerationType="class">
<attribute name="ackError" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ackSNR" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="ackTimestamp" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="admin" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="adminDescription" optional="YES" attributeType="String"/>
<attribute name="channel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isEmoji" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="messageId" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="messagePayload" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="messagePayloadMarkdown" optional="YES" attributeType="String"/>
<attribute name="messageTimestamp" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pkiEncrypted" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="portNum" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="read" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="realACK" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="receivedACK" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="replyID" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="fromUser" optional="YES" maxCount="1" deletionRule="Nullify" ordered="YES" destinationEntity="UserEntity" inverseName="sentMessages" inverseEntity="UserEntity"/>
<relationship name="toUser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="receivedMessages" inverseEntity="UserEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="messageId"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="MQTTConfigEntity" representedClassName="MQTTConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="address" optional="YES" attributeType="String"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="encryptionEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="jsonEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="mapPositionPrecision" optional="YES" attributeType="Integer 32" defaultValueString="13" usesScalarValueType="YES"/>
<attribute name="mapPublishIntervalSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="mapReportingEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="mapReportingShouldReportLocation" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="password" optional="YES" attributeType="String" maxValueString="30"/>
<attribute name="proxyToClientEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="root" optional="YES" attributeType="String" defaultValueString="msh"/>
<attribute name="tlsEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="username" optional="YES" attributeType="String" maxValueString="30"/>
<relationship name="mqttConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="mqttConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="MyInfoEntity" representedClassName="MyInfoEntity" syncable="YES" codeGenerationType="class">
<attribute name="adminIndex" optional="YES" attributeType="Integer 32" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="bleName" optional="YES" attributeType="String"/>
<attribute name="deviceId" optional="YES" attributeType="Binary"/>
<attribute name="minAppVersion" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="myNodeNum" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="peripheralId" optional="YES" attributeType="String"/>
<attribute name="rebootCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="registered" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="channels" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="ChannelEntity" inverseName="myInfoChannel" inverseEntity="ChannelEntity"/>
<relationship name="myInfoNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="myInfo" inverseEntity="NodeInfoEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="myNodeNum"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="NetworkConfigEntity" representedClassName="NetworkConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="dns" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="enabledProtocols" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ethEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="gateway" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ip" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ntpServer" optional="YES" attributeType="String"/>
<attribute name="subnet" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifiEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="wifiMode" optional="YES" attributeType="Integer 32" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="wifiPsk" optional="YES" attributeType="String" minValueString="0" maxValueString="60"/>
<attribute name="wifiSsid" optional="YES" attributeType="String" minValueString="0" maxValueString="30"/>
<relationship name="networkConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="networkConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="NodeInfoEntity" representedClassName="NodeInfoEntity" syncable="YES" codeGenerationType="class">
<attribute name="bleName" optional="YES" attributeType="String"/>
<attribute name="channel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="favorite" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="firstHeard" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="hopsAway" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ignored" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastHeard" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="num" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="peripheralId" optional="YES" attributeType="String"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sessionExpiration" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sessionPasskey" optional="YES" attributeType="Binary"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="viaMqtt" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="ambientLightingConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="AmbientLightingConfigEntity" inverseName="ambientLightingConfigNode" inverseEntity="AmbientLightingConfigEntity"/>
<relationship name="bluetoothConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="BluetoothConfigEntity" inverseName="bluetoothConfigNode" inverseEntity="BluetoothConfigEntity"/>
<relationship name="cannedMessageConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="CannedMessageConfigEntity" inverseName="cannedMessagesConfigNode" inverseEntity="CannedMessageConfigEntity"/>
<relationship name="detectionSensorConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DetectionSensorConfigEntity" inverseName="detectionSensorConfigNode" inverseEntity="DetectionSensorConfigEntity"/>
<relationship name="deviceConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DeviceConfigEntity" inverseName="deviceConfigNode" inverseEntity="DeviceConfigEntity"/>
<relationship name="displayConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DisplayConfigEntity" inverseName="displayConfigNode" inverseEntity="DisplayConfigEntity"/>
<relationship name="externalNotificationConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ExternalNotificationConfigEntity" inverseName="externalNotificationConfigNode" inverseEntity="ExternalNotificationConfigEntity"/>
<relationship name="loRaConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LoRaConfigEntity" inverseName="loRaConfigNode" inverseEntity="LoRaConfigEntity"/>
<relationship name="metadata" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="DeviceMetadataEntity" inverseName="metadataNode" inverseEntity="DeviceMetadataEntity"/>
<relationship name="mqttConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MQTTConfigEntity" inverseName="mqttConfigNode" inverseEntity="MQTTConfigEntity"/>
<relationship name="myInfo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MyInfoEntity" inverseName="myInfoNode" inverseEntity="MyInfoEntity"/>
<relationship name="networkConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NetworkConfigEntity" inverseName="networkConfigNode" inverseEntity="NetworkConfigEntity"/>
<relationship name="pax" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="PaxCounterEntity" inverseName="paxNode" inverseEntity="PaxCounterEntity"/>
<relationship name="paxCounterConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PaxCounterConfigEntity" inverseName="paxCounterConfigNode" inverseEntity="PaxCounterConfigEntity"/>
<relationship name="positionConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PositionConfigEntity" inverseName="positionConfigNode" inverseEntity="PositionConfigEntity"/>
<relationship name="positions" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="PositionEntity" inverseName="nodePosition" inverseEntity="PositionEntity"/>
<relationship name="powerConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PowerConfigEntity" inverseName="powerConfigNode" inverseEntity="PowerConfigEntity"/>
<relationship name="rangeTestConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RangeTestConfigEntity" inverseName="rangeTestConfigNode" inverseEntity="RangeTestConfigEntity"/>
<relationship name="rtttlConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RTTTLConfigEntity" inverseName="rtttlConfigNode" inverseEntity="RTTTLConfigEntity"/>
<relationship name="securityConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SecurityConfigEntity" inverseName="securityConfigNode" inverseEntity="SecurityConfigEntity"/>
<relationship name="serialConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SerialConfigEntity" inverseName="serialConfigNode" inverseEntity="SerialConfigEntity"/>
<relationship name="storeForwardConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreForwardConfigEntity" inverseName="storeForwardConfigNode" inverseEntity="StoreForwardConfigEntity"/>
<relationship name="telemetries" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="TelemetryEntity" inverseName="nodeTelemetry" inverseEntity="TelemetryEntity"/>
<relationship name="telemetryConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="TelemetryConfigEntity" inverseName="telemetryConfigNode" inverseEntity="TelemetryConfigEntity"/>
<relationship name="traceRoutes" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="TraceRouteEntity" inverseName="node" inverseEntity="TraceRouteEntity"/>
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="userNode" inverseEntity="UserEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="num"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="PaxCounterConfigEntity" representedClassName="PaxCounterConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="bleThreshold" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifiThreshold" optional="YES" attributeType="Integer 32" defaultValueString="-80" usesScalarValueType="YES"/>
<relationship name="paxCounterConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="paxCounterConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PaxCounterEntity" representedClassName="PaxCounterEntity" syncable="YES" codeGenerationType="class">
<attribute name="ble" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uptime" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="paxNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="pax" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PositionConfigEntity" representedClassName="PositionConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="broadcastSmartMinimumDistance" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="broadcastSmartMinimumIntervalSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="deviceGpsEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fixedPosition" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="gpsAttemptTime" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsEnGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="positionBroadcastSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="positionFlags" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rxGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="smartPositionEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="txGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="positionConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="positionConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PositionEntity" representedClassName="PositionEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="heading" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="latest" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="latitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="precisionBits" optional="YES" attributeType="Integer 32" defaultValueString="32" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="satsInView" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="seqNo" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="speed" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="nodePosition" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="positions" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PowerConfigEntity" representedClassName="PowerConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="adcMultiplierOverride" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="deviceBatteryInaAddress" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isPowerSaving" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="lsSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="minWakeSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="onBatteryShutdownAfterSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="waitBluetoothSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="powerConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="powerConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="RangeTestConfigEntity" representedClassName="RangeTestConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="save" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="sender" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<relationship name="rangeTestConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="rangeTestConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="RouteEntity" representedClassName="RouteEntity" syncable="YES" codeGenerationType="class">
<attribute name="color" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="distance" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="elevationGain" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="endDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<relationship name="locations" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="LocationEntity" inverseName="routeLocation" inverseEntity="LocationEntity"/>
</entity>
<entity name="RTTTLConfigEntity" representedClassName="RTTTLConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="ringtone" optional="YES" attributeType="String" maxValueString="228" defaultValueString=""/>
<relationship name="rtttlConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="rtttlConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="SecurityConfigEntity" representedClassName="SecurityConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="adminChannelEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="adminKey" optional="YES" attributeType="Binary"/>
<attribute name="adminKey2" optional="YES" attributeType="Binary"/>
<attribute name="adminKey3" optional="YES" attributeType="Binary"/>
<attribute name="bluetoothLoggingEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="debugLogApiEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isManaged" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="privateKey" optional="YES" attributeType="Binary"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="serialEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="securityConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="securityConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="SerialConfigEntity" representedClassName="SerialConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="baudRate" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="echo" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="overrideConsoleSerialPort" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="rxd" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="timeout" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="txd" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="serialConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="serialConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="StoreForwardConfigEntity" representedClassName="StoreForwardConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="heartbeat" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="historyReturnMax" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="historyReturnWindow" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isRouter" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastHeartbeat" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="lastRequest" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="records" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="storeForwardConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="storeForwardConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TelemetryConfigEntity" representedClassName="TelemetryConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="deviceUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="environmentDisplayFahrenheit" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentMeasurementEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentScreenEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="powerMeasurementEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="powerScreenEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="powerUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="telemetryConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetryConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TelemetryEntity" representedClassName="TelemetryEntity" syncable="YES">
<attribute name="airUtilTx" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="barometricPressure" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="batteryLevel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="channelUtilization" optional="YES" attributeType="Float" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="gasResistance" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="iaq" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="irLux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="lux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="metricsType" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numOnlineNodes" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsRx" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsRxBad" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsTx" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numRxDupe" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numTotalNodes" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numTxRelay" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numTxRelayCanceled" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="powerCh1Current" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh1Voltage" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh2Current" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh2Voltage" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh3Current" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh3Voltage" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="radiation" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="rainfall1H" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="rainfall24H" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="relativeHumidity" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="soilMoisture" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="soilTemperature" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="temperature" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uptimeSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="uvLux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="voltage" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="weight" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="whiteLux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windDirection" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="windGust" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windLull" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windSpeed" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="nodeTelemetry" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetries" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TraceRouteEntity" representedClassName="TraceRouteEntity" syncable="YES" codeGenerationType="class">
<attribute name="hasPositions" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="hopsBack" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="hopsTowards" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="response" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="routeBackText" optional="YES" attributeType="String"/>
<attribute name="routeText" optional="YES" attributeType="String"/>
<attribute name="sent" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="hops" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="TraceRouteHopEntity" inverseName="traceRoute" inverseEntity="TraceRouteHopEntity"/>
<relationship name="node" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="traceRoutes" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TraceRouteHopEntity" representedClassName="TraceRouteHopEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="back" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="latitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="num" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="time" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="traceRoute" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="TraceRouteEntity" inverseName="hops" inverseEntity="TraceRouteEntity"/>
</entity>
<entity name="UserEntity" representedClassName="UserEntity" syncable="YES" codeGenerationType="class">
<attribute name="hwDisplayName" optional="YES" attributeType="String"/>
<attribute name="hwModel" attributeType="String"/>
<attribute name="hwModelId" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isLicensed" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="keyMatch" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="lastMessage" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="longName" attributeType="String"/>
<attribute name="mute" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="newPublicKey" optional="YES" attributeType="Binary"/>
<attribute name="num" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numString" optional="YES" attributeType="String"/>
<attribute name="pkiEncrypted" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="role" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="shortName" attributeType="String"/>
<attribute name="unmessagable" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="userId" attributeType="String"/>
<relationship name="receivedMessages" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="MessageEntity" inverseName="toUser" inverseEntity="MessageEntity"/>
<relationship name="sentMessages" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="MessageEntity" inverseName="fromUser" inverseEntity="MessageEntity"/>
<relationship name="userNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="user" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="WaypointEntity" representedClassName="WaypointEntity" syncable="YES" codeGenerationType="class">
<attribute name="created" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="expire" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="icon" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="latitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="locked" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="longDescription" optional="YES" attributeType="String" maxValueString="100"/>
<attribute name="longitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String" minValueString="1" maxValueString="30"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
</model>

View file

@ -0,0 +1,506 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24D81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AmbientLightingConfigEntity" representedClassName="AmbientLightingConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="blue" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="green" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ledState" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="red" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="ambientLightingConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="ambientLightingConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="BluetoothConfigEntity" representedClassName="BluetoothConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="deviceLoggingEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fixedPin" optional="YES" attributeType="Integer 32" defaultValueString="123456" usesScalarValueType="YES"/>
<attribute name="mode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="bluetoothConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="bluetoothConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="CannedMessageConfigEntity" representedClassName="CannedMessageConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventCcw" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventCw" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventPress" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinA" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinB" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinPress" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="messages" optional="YES" attributeType="String" minValueString="0" maxValueString="198"/>
<attribute name="rotary1Enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="sendBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updown1Enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="cannedMessagesConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="cannedMessageConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="ChannelEntity" representedClassName="ChannelEntity" syncable="YES" codeGenerationType="class">
<attribute name="downlinkEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="index" attributeType="Integer 32" minValueString="0" maxValueString="13" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="mute" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="positionPrecision" optional="YES" attributeType="Integer 32" defaultValueString="32" usesScalarValueType="YES"/>
<attribute name="psk" optional="YES" attributeType="Binary"/>
<attribute name="role" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="uplinkEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="myInfoChannel" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MyInfoEntity" inverseName="channels" inverseEntity="MyInfoEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="index"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="DetectionSensorConfigEntity" representedClassName="DetectionSensorConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="minimumBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="monitorPin" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="sendBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="stateBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="triggerType" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="usePullup" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="detectionSensorConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="detectionSensorConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DeviceConfigEntity" representedClassName="DeviceConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="buttonGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="buzzerGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="debugLogEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="disableTripleClick" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="doubleTapAsButtonPress" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="isManaged" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="ledHeartbeatEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="nodeInfoBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rebroadcastMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="role" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="serialEnabled" optional="YES" attributeType="Boolean" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="tripleClickAsAdHocPing" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="tzdef" optional="YES" attributeType="String"/>
<relationship name="deviceConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="deviceConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DeviceMetadataEntity" representedClassName="DeviceMetadataEntity" syncable="YES" codeGenerationType="class">
<attribute name="canShutdown" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="deviceStateVersion" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="excludedModules" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="firmwareVersion" optional="YES" attributeType="String"/>
<attribute name="hasBluetooth" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hasEthernet" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hasWifi" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hwModel" optional="YES" attributeType="String"/>
<attribute name="positionFlags" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="role" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="metadataNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="metadata" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DisplayConfigEntity" representedClassName="DisplayConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="compassNorthTop" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="displayMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="flipScreen" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="gpsFormat" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="headingBold" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="oledType" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="screenCarouselInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="screenOnSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="units" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="use12HClock" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="wakeOnTapOrMotion" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="displayConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="displayConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="ExternalNotificationConfigEntity" representedClassName="ExternalNotificationConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="active" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBellBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBellVibra" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessage" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessageBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessageVibra" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="nagTimeout" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="output" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputBuzzer" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputMilliseconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputVibra" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="useI2SAsBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="usePWM" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<relationship name="externalNotificationConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="externalNotificationConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="LocationEntity" representedClassName="LocationEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="heading" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="latitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="speed" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="routeLocation" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RouteEntity" inverseName="locations" inverseEntity="RouteEntity"/>
</entity>
<entity name="LoRaConfigEntity" representedClassName="LoRaConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="bandwidth" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="channelNum" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="codingRate" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="frequencyOffset" optional="YES" attributeType="Float" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="hopLimit" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ignoreMqtt" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="modemPreset" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="okToMqtt" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="overrideDutyCycle" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="overrideFrequency" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="regionCode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="spreadFactor" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sx126xRxBoostedGain" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="txEnabled" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="txPower" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="usePreset" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<relationship name="loRaConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="loRaConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="MessageEntity" representedClassName="MessageEntity" syncable="YES" codeGenerationType="class">
<attribute name="ackError" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ackSNR" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="ackTimestamp" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="admin" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="adminDescription" optional="YES" attributeType="String"/>
<attribute name="channel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isEmoji" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="messageId" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="messagePayload" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="messagePayloadMarkdown" optional="YES" attributeType="String"/>
<attribute name="messageTimestamp" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pkiEncrypted" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="portNum" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="read" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="realACK" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="receivedACK" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="replyID" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="fromUser" optional="YES" maxCount="1" deletionRule="Nullify" ordered="YES" destinationEntity="UserEntity" inverseName="sentMessages" inverseEntity="UserEntity"/>
<relationship name="toUser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="receivedMessages" inverseEntity="UserEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="messageId"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="MQTTConfigEntity" representedClassName="MQTTConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="address" optional="YES" attributeType="String"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="encryptionEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="jsonEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="mapPositionPrecision" optional="YES" attributeType="Integer 32" defaultValueString="13" usesScalarValueType="YES"/>
<attribute name="mapPublishIntervalSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="mapReportingEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="mapReportingShouldReportLocation" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="password" optional="YES" attributeType="String" maxValueString="30"/>
<attribute name="proxyToClientEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="root" optional="YES" attributeType="String" defaultValueString="msh"/>
<attribute name="tlsEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="username" optional="YES" attributeType="String" maxValueString="30"/>
<relationship name="mqttConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="mqttConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="MyInfoEntity" representedClassName="MyInfoEntity" syncable="YES" codeGenerationType="class">
<attribute name="bleName" optional="YES" attributeType="String"/>
<attribute name="deviceId" optional="YES" attributeType="Binary"/>
<attribute name="minAppVersion" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="myNodeNum" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="peripheralId" optional="YES" attributeType="String"/>
<attribute name="rebootCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="registered" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="channels" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="ChannelEntity" inverseName="myInfoChannel" inverseEntity="ChannelEntity"/>
<relationship name="myInfoNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="myInfo" inverseEntity="NodeInfoEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="myNodeNum"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="NetworkConfigEntity" representedClassName="NetworkConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="dns" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="enabledProtocols" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ethEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="gateway" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ip" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ntpServer" optional="YES" attributeType="String"/>
<attribute name="subnet" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifiEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="wifiMode" optional="YES" attributeType="Integer 32" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="wifiPsk" optional="YES" attributeType="String" minValueString="0" maxValueString="60"/>
<attribute name="wifiSsid" optional="YES" attributeType="String" minValueString="0" maxValueString="30"/>
<relationship name="networkConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="networkConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="NodeInfoEntity" representedClassName="NodeInfoEntity" syncable="YES" codeGenerationType="class">
<attribute name="bleName" optional="YES" attributeType="String"/>
<attribute name="channel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="favorite" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="firstHeard" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="hopsAway" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ignored" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastHeard" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="num" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="peripheralId" optional="YES" attributeType="String"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sessionExpiration" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sessionPasskey" optional="YES" attributeType="Binary"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="viaMqtt" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="ambientLightingConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="AmbientLightingConfigEntity" inverseName="ambientLightingConfigNode" inverseEntity="AmbientLightingConfigEntity"/>
<relationship name="bluetoothConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="BluetoothConfigEntity" inverseName="bluetoothConfigNode" inverseEntity="BluetoothConfigEntity"/>
<relationship name="cannedMessageConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="CannedMessageConfigEntity" inverseName="cannedMessagesConfigNode" inverseEntity="CannedMessageConfigEntity"/>
<relationship name="detectionSensorConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DetectionSensorConfigEntity" inverseName="detectionSensorConfigNode" inverseEntity="DetectionSensorConfigEntity"/>
<relationship name="deviceConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DeviceConfigEntity" inverseName="deviceConfigNode" inverseEntity="DeviceConfigEntity"/>
<relationship name="displayConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DisplayConfigEntity" inverseName="displayConfigNode" inverseEntity="DisplayConfigEntity"/>
<relationship name="externalNotificationConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ExternalNotificationConfigEntity" inverseName="externalNotificationConfigNode" inverseEntity="ExternalNotificationConfigEntity"/>
<relationship name="loRaConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LoRaConfigEntity" inverseName="loRaConfigNode" inverseEntity="LoRaConfigEntity"/>
<relationship name="metadata" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="DeviceMetadataEntity" inverseName="metadataNode" inverseEntity="DeviceMetadataEntity"/>
<relationship name="mqttConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MQTTConfigEntity" inverseName="mqttConfigNode" inverseEntity="MQTTConfigEntity"/>
<relationship name="myInfo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MyInfoEntity" inverseName="myInfoNode" inverseEntity="MyInfoEntity"/>
<relationship name="networkConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NetworkConfigEntity" inverseName="networkConfigNode" inverseEntity="NetworkConfigEntity"/>
<relationship name="pax" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="PaxCounterEntity" inverseName="paxNode" inverseEntity="PaxCounterEntity"/>
<relationship name="paxCounterConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PaxCounterConfigEntity" inverseName="paxCounterConfigNode" inverseEntity="PaxCounterConfigEntity"/>
<relationship name="positionConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PositionConfigEntity" inverseName="positionConfigNode" inverseEntity="PositionConfigEntity"/>
<relationship name="positions" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="PositionEntity" inverseName="nodePosition" inverseEntity="PositionEntity"/>
<relationship name="powerConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PowerConfigEntity" inverseName="powerConfigNode" inverseEntity="PowerConfigEntity"/>
<relationship name="rangeTestConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RangeTestConfigEntity" inverseName="rangeTestConfigNode" inverseEntity="RangeTestConfigEntity"/>
<relationship name="rtttlConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RTTTLConfigEntity" inverseName="rtttlConfigNode" inverseEntity="RTTTLConfigEntity"/>
<relationship name="securityConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SecurityConfigEntity" inverseName="securityConfigNode" inverseEntity="SecurityConfigEntity"/>
<relationship name="serialConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SerialConfigEntity" inverseName="serialConfigNode" inverseEntity="SerialConfigEntity"/>
<relationship name="storeForwardConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreForwardConfigEntity" inverseName="storeForwardConfigNode" inverseEntity="StoreForwardConfigEntity"/>
<relationship name="telemetries" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="TelemetryEntity" inverseName="nodeTelemetry" inverseEntity="TelemetryEntity"/>
<relationship name="telemetryConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="TelemetryConfigEntity" inverseName="telemetryConfigNode" inverseEntity="TelemetryConfigEntity"/>
<relationship name="traceRoutes" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="TraceRouteEntity" inverseName="node" inverseEntity="TraceRouteEntity"/>
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="userNode" inverseEntity="UserEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="num"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="PaxCounterConfigEntity" representedClassName="PaxCounterConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="bleThreshold" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifiThreshold" optional="YES" attributeType="Integer 32" defaultValueString="-80" usesScalarValueType="YES"/>
<relationship name="paxCounterConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="paxCounterConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PaxCounterEntity" representedClassName="PaxCounterEntity" syncable="YES" codeGenerationType="class">
<attribute name="ble" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uptime" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="paxNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="pax" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PositionConfigEntity" representedClassName="PositionConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="broadcastSmartMinimumDistance" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="broadcastSmartMinimumIntervalSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="deviceGpsEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fixedPosition" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="gpsAttemptTime" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsEnGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="positionBroadcastSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="positionFlags" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rxGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="smartPositionEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="txGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="positionConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="positionConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PositionEntity" representedClassName="PositionEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="heading" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="latest" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="latitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="precisionBits" optional="YES" attributeType="Integer 32" defaultValueString="32" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="satsInView" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="seqNo" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="speed" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="nodePosition" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="positions" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PowerConfigEntity" representedClassName="PowerConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="adcMultiplierOverride" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="deviceBatteryInaAddress" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isPowerSaving" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="lsSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="minWakeSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="onBatteryShutdownAfterSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="waitBluetoothSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="powerConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="powerConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="RangeTestConfigEntity" representedClassName="RangeTestConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="save" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="sender" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<relationship name="rangeTestConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="rangeTestConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="RouteEntity" representedClassName="RouteEntity" syncable="YES" codeGenerationType="class">
<attribute name="color" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="distance" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="elevationGain" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="endDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<relationship name="locations" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="LocationEntity" inverseName="routeLocation" inverseEntity="LocationEntity"/>
</entity>
<entity name="RTTTLConfigEntity" representedClassName="RTTTLConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="ringtone" optional="YES" attributeType="String" maxValueString="228" defaultValueString=""/>
<relationship name="rtttlConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="rtttlConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="SecurityConfigEntity" representedClassName="SecurityConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="adminChannelEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="adminKey" optional="YES" attributeType="Binary"/>
<attribute name="adminKey2" optional="YES" attributeType="Binary"/>
<attribute name="adminKey3" optional="YES" attributeType="Binary"/>
<attribute name="bluetoothLoggingEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="debugLogApiEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isManaged" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="privateKey" optional="YES" attributeType="Binary"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="serialEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="securityConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="securityConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="SerialConfigEntity" representedClassName="SerialConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="baudRate" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="echo" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="overrideConsoleSerialPort" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="rxd" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="timeout" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="txd" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="serialConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="serialConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="StoreForwardConfigEntity" representedClassName="StoreForwardConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="heartbeat" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="historyReturnMax" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="historyReturnWindow" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isRouter" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastHeartbeat" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="lastRequest" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="records" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="storeForwardConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="storeForwardConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TelemetryConfigEntity" representedClassName="TelemetryConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="deviceUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="environmentDisplayFahrenheit" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentMeasurementEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentScreenEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="powerMeasurementEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="powerScreenEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="powerUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="telemetryConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetryConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TelemetryEntity" representedClassName="TelemetryEntity" syncable="YES">
<attribute name="airUtilTx" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="barometricPressure" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="batteryLevel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="channelUtilization" optional="YES" attributeType="Float" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="gasResistance" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="iaq" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="irLux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="lux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="metricsType" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numOnlineNodes" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsRx" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsRxBad" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsTx" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numRxDupe" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numTotalNodes" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numTxRelay" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numTxRelayCanceled" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="powerCh1Current" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh1Voltage" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh2Current" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh2Voltage" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh3Current" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="powerCh3Voltage" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="radiation" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="rainfall1H" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="rainfall24H" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="relativeHumidity" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="soilMoisture" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="soilTemperature" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="temperature" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uptimeSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="uvLux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="voltage" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="weight" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="whiteLux" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windDirection" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="windGust" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windLull" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windSpeed" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="nodeTelemetry" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetries" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TraceRouteEntity" representedClassName="TraceRouteEntity" syncable="YES" codeGenerationType="class">
<attribute name="hasPositions" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="hopsBack" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="hopsTowards" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="response" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="routeBackText" optional="YES" attributeType="String"/>
<attribute name="routeText" optional="YES" attributeType="String"/>
<attribute name="sent" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="hops" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="TraceRouteHopEntity" inverseName="traceRoute" inverseEntity="TraceRouteHopEntity"/>
<relationship name="node" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="traceRoutes" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TraceRouteHopEntity" representedClassName="TraceRouteHopEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="back" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="latitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="num" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="time" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="traceRoute" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="TraceRouteEntity" inverseName="hops" inverseEntity="TraceRouteEntity"/>
</entity>
<entity name="UserEntity" representedClassName="UserEntity" syncable="YES" codeGenerationType="class">
<attribute name="hwDisplayName" optional="YES" attributeType="String"/>
<attribute name="hwModel" attributeType="String"/>
<attribute name="hwModelId" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isLicensed" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="keyMatch" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="lastMessage" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="longName" attributeType="String"/>
<attribute name="mute" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="newPublicKey" optional="YES" attributeType="Binary"/>
<attribute name="num" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numString" optional="YES" attributeType="String"/>
<attribute name="pkiEncrypted" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="role" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="shortName" attributeType="String"/>
<attribute name="unmessagable" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="userId" attributeType="String"/>
<relationship name="receivedMessages" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="MessageEntity" inverseName="toUser" inverseEntity="MessageEntity"/>
<relationship name="sentMessages" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="MessageEntity" inverseName="fromUser" inverseEntity="MessageEntity"/>
<relationship name="userNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="user" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="WaypointEntity" representedClassName="WaypointEntity" syncable="YES" codeGenerationType="class">
<attribute name="created" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="expire" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="icon" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="latitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="locked" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="longDescription" optional="YES" attributeType="String" maxValueString="100"/>
<attribute name="longitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String" minValueString="1" maxValueString="30"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
</model>

View file

@ -4,14 +4,17 @@ import SwiftUI
import CoreData
import OSLog
import TipKit
import MeshtasticProtobufs
import DatadogCore
import DatadogCrashReporting
import DatadogRUM
@main
struct MeshtasticAppleApp: App {
@UIApplicationDelegateAdaptor(MeshtasticAppDelegate.self)
private var appDelegate
@UIApplicationDelegateAdaptor(MeshtasticAppDelegate.self) private var appDelegate
@ObservedObject var appState: AppState
@ObservedObject var appState: AppState
private let persistenceController: PersistenceController
@ -26,37 +29,71 @@ struct MeshtasticAppleApp: App {
let appState = AppState(
router: Router()
)
self._appState = ObservedObject(wrappedValue: appState)
// Initialize Datadog
// RUM Client Tokens are NOT secret
let appID = "79fe92a9-74c9-4c8f-ba63-6308384ecfa9"
let clientToken = "pub4427bea20dbdb08a6af68034de22cd3b"
let environment = "testflight"
Datadog.initialize(
with: Datadog.Configuration(
clientToken: clientToken,
env: environment,
site: .us5
),
trackingConsent: UserDefaults.usageDataAndCrashReporting ? .granted : .notGranted
)
RUM.enable(
with: RUM.Configuration(
applicationID: appID,
uiKitViewsPredicate: DefaultUIKitRUMViewsPredicate(),
uiKitActionsPredicate: DefaultUIKitRUMActionsPredicate()
)
)
self._appState = ObservedObject(wrappedValue: appState)
// Initialize the BLEManager singleton with the necessary dependencies
BLEManager.setup(appState: appState, context: persistenceController.container.viewContext)
self.persistenceController = persistenceController
// Wire up router
self.appDelegate.router = appState.router
// Show Tips
#if DEBUG
// Show tips in development
try? Tips.resetDatastore()
#endif
}
var body: some Scene {
WindowGroup {
ContentView(
appState: appState,
router: appState.router
)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environmentObject(appState)
.environmentObject(BLEManager.shared)
.sheet(isPresented: $saveChannels) {
SaveChannelQRCode(channelSetLink: channelSettings ?? "Empty Channel URL", addChannels: addChannels, bleManager: BLEManager.shared)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.sheet(isPresented: Binding(
get: {
saveChannels && !(channelSettings == nil)
},
set: { newValue in
saveChannels = newValue
if !newValue {
channelSettings = nil
}
}
)) {
SaveChannelQRCode(
channelSetLink: channelSettings ?? "Empty Channel URL",
addChannels: addChannels,
bleManager: BLEManager.shared
)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in
Logger.mesh.debug("URL received \(userActivity, privacy: .public)")
self.incomingUrl = userActivity.webpageURL
if (self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/e/#")) != nil {
self.saveChannels = false
if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/v/#") == true {
ContactURLHandler.handleContactUrl(url: self.incomingUrl!, bleManager: BLEManager.shared)
} else if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/e/") == true {
if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") {
self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false
if (self.incomingUrl?.absoluteString.lowercased().contains("?")) != nil {
@ -72,7 +109,7 @@ struct MeshtasticAppleApp: App {
}
Logger.services.debug("Add Channel \(self.addChannels, privacy: .public)")
}
self.saveChannels = true
self.saveChannels = true
Logger.mesh.debug("User wants to open a Channel Settings URL: \(self.incomingUrl?.absoluteString ?? "No QR Code Link")")
}
if self.saveChannels {
@ -80,10 +117,11 @@ struct MeshtasticAppleApp: App {
}
}
.onOpenURL(perform: { (url) in
Logger.mesh.debug("Some sort of URL was received \(url, privacy: .public)")
self.incomingUrl = url
if url.absoluteString.lowercased().contains("meshtastic.org/e/#") {
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
ContactURLHandler.handleContactUrl(url: url, bleManager: BLEManager.shared)
} else if url.absoluteString.lowercased().contains("meshtastic.org/e/") {
if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") {
self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false
if self.incomingUrl?.absoluteString.lowercased().contains("?") != nil {
@ -99,7 +137,7 @@ struct MeshtasticAppleApp: App {
}
Logger.services.debug("Add Channel \(self.addChannels, privacy: .public)")
}
self.saveChannels = true
self.saveChannels = true
Logger.mesh.debug("User wants to open a Channel Settings URL: \(self.incomingUrl?.absoluteString ?? "No QR Code Link", privacy: .public)")
} else if url.absoluteString.lowercased().contains("meshtastic:///") {
appState.router.route(url: url)
@ -115,7 +153,7 @@ struct MeshtasticAppleApp: App {
.displayFrequency(.immediate)
]
)
}
}
}
.onChange(of: scenePhase) { (_, newScenePhase) in
switch newScenePhase {
@ -138,5 +176,9 @@ struct MeshtasticAppleApp: App {
Logger.services.error("🍎 [App] Apple must have changed something")
}
}
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environmentObject(appState)
.environmentObject(BLEManager.shared)
}
}

View file

@ -97,7 +97,22 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
if let targetValue = userInfo["target"] as? String,
let deepLink = userInfo["path"] as? String,
let url = URL(string: deepLink) {
Logger.services.info("userNotificationCenter didReceiveResponse \(targetValue, privacy: .public) \(deepLink, privacy: .public)")
Logger.services.info("userNotificationCenter didReceiveResponse handling deeplink: \(targetValue, privacy: .public) \(deepLink, privacy: .public)")
// Handle TraceRoute notifications specially to ensure they navigate correctly
if deepLink.contains("meshtastic:///nodes") && deepLink.contains("nodenum=") {
// First extract the node number from the URL
if let nodeNumString = deepLink.components(separatedBy: "nodenum=").last,
let nodeNum = Int64(nodeNumString) {
Logger.services.info("Navigation to specific node via notification: \(nodeNum, privacy: .public)")
self.router?.navigationState.selectedTab = .nodes
// Post a notification to trigger app-wide refresh
NotificationCenter.default.post(name: NSNotification.Name("ForceNavigationRefresh"),
object: nil,
userInfo: ["nodeNum": nodeNum])
self.router?.navigationState.nodeListSelectedNodeNum = nodeNum
}
}
// Still call the regular router in all cases
router?.route(url: url)
} else {
Logger.services.error("Failed to handle notification response: \(userInfo, privacy: .public)")

View file

@ -8,6 +8,57 @@ import CoreData
import MeshtasticProtobufs
import OSLog
// MARK: - Safe Conversion Helpers
private func safeInt32(from value: UInt32) -> Int32 {
return Int32(clamping: value)
}
private func safeInt32(from value: Int) -> Int32 {
return Int32(clamping: value)
}
private func safeInt32(from value: UInt64) -> Int32 {
return Int32(clamping: value)
}
public func clearStaleNodes(nodeExpireDays: Int, context: NSManagedObjectContext) -> Bool {
var nodeExpireTime: TimeInterval {
return TimeInterval(-nodeExpireDays * 86400)
}
var nodePKIExpireTime: TimeInterval {
return TimeInterval((nodeExpireDays < 7 ? -7 : -nodeExpireDays) * 86400)
}
if nodeExpireDays == 0 {
// Purge Disabled
Logger.data.info("💾 [NodeInfoEntity] Skip clearing stale nodes")
return false
}
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "NodeInfoEntity")
fetchRequest.predicate = NSPredicate(format: "favorite == false AND ignored == false AND ((user.pkiEncrypted == NO AND lastHeard < %@) OR (user.pkiEncrypted == YES AND lastHeard < %@))",
NSDate(timeIntervalSinceNow: nodeExpireTime), NSDate(timeIntervalSinceNow: nodePKIExpireTime))
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDeleteRequest.resultType = .resultTypeCount
do {
Logger.data.info("💾 [NodeInfoEntity] Clearing nodes older than \(nodeExpireDays) days")
if let batchDeleteResult = try context.execute(batchDeleteRequest) as? NSBatchDeleteResult {
try context.save()
let deletedNodes = batchDeleteResult.result as? Int ?? 0
Logger.data.info("💾 [NodeInfoEntity] Cleared \(deletedNodes) stale nodes")
if deletedNodes > 0 {
return true
}
} else {
Logger.data.error("💥 [NodeInfoEntity] bad delete results")
}
} catch {
context.rollback()
Logger.data.error("💥 [NodeInfoEntity] Error deleting stale nodes")
}
return false
}
public func clearPax(destNum: Int64, context: NSManagedObjectContext) -> Bool {
let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest()
@ -160,7 +211,9 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext)
newNode.channel = Int32(packet.channel)
}
if let nodeInfoMessage = try? NodeInfo(serializedBytes: packet.decoded.payload) {
newNode.hopsAway = Int32(nodeInfoMessage.hopsAway)
if nodeInfoMessage.hasHopsAway {
newNode.hopsAway = Int32(nodeInfoMessage.hopsAway)
}
newNode.favorite = nodeInfoMessage.isFavorite
}
@ -168,19 +221,37 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext)
if newUserMessage.id.isEmpty {
if packet.from > Constants.minimumNodeNum {
let newUser = createUser(num: Int64(packet.from), context: context)
newNode.user = newUser
do {
let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context)
newNode.user = newUser
} catch CoreDataError.invalidInput(let message) {
Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)")
} catch {
Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)")
}
}
} else {
let newUser = UserEntity(context: context)
newUser.userId = newUserMessage.id
newUser.userId = newNode.num.toHex()
newUser.num = Int64(packet.from)
newUser.longName = newUserMessage.longName
newUser.shortName = newUserMessage.shortName
newUser.role = Int32(newUserMessage.role.rawValue)
newUser.hwModel = String(describing: newUserMessage.hwModel).uppercased()
newUser.hwModelId = Int32(newUserMessage.hwModel.rawValue)
/// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default
if newUserMessage.hasIsUnmessagable {
newUser.unmessagable = newUserMessage.isUnmessagable
} else {
let roles = [2, 4, 5, 6, 7, 10, 11]
let containsRole = roles.contains(Int(newUser.role))
if containsRole {
newUser.unmessagable = true
} else {
newUser.unmessagable = false
}
}
if !newUserMessage.publicKey.isEmpty {
newUser.pkiEncrypted = true
newUser.publicKey = newUserMessage.publicKey
@ -211,17 +282,34 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext)
}
} else {
if packet.from > Constants.minimumNodeNum {
let newUser = createUser(num: Int64(packet.from), context: context)
if !packet.publicKey.isEmpty {
newNode.user?.pkiEncrypted = true
newNode.user?.publicKey = packet.publicKey
do {
let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context)
if !packet.publicKey.isEmpty {
newNode.user?.pkiEncrypted = true
newNode.user?.publicKey = packet.publicKey
}
newNode.user = newUser
} catch CoreDataError.invalidInput(let message) {
Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)")
} catch {
Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)")
}
newNode.user = newUser
}
}
// User is messed up and has failed to create at least once, if this fails bail out
if newNode.user == nil && packet.from > Constants.minimumNodeNum {
newNode.user = createUser(num: Int64(packet.from), context: context)
do {
let newUser = try createUser(num: Int64(packet.from), context: context)
newNode.user = newUser
} catch CoreDataError.invalidInput(let message) {
Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)")
context.rollback()
return
} catch {
Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)")
context.rollback()
return
}
}
let myInfoEntity = MyInfoEntity(context: context)
@ -269,22 +357,33 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext)
fetchedNode[0].telemetries? = NSOrderedSet(array: newTelemetries)
}
if nodeInfoMessage.hasUser {
/// Seeing Some crashes here ?
fetchedNode[0].user!.userId = nodeInfoMessage.user.id
fetchedNode[0].user!.num = Int64(nodeInfoMessage.num)
fetchedNode[0].user!.longName = nodeInfoMessage.user.longName
fetchedNode[0].user!.shortName = nodeInfoMessage.user.shortName
fetchedNode[0].user!.role = Int32(nodeInfoMessage.user.role.rawValue)
fetchedNode[0].user!.hwModel = String(describing: nodeInfoMessage.user.hwModel).uppercased()
fetchedNode[0].user!.hwModelId = Int32(nodeInfoMessage.user.hwModel.rawValue)
fetchedNode[0].user?.userId = nodeInfoMessage.num.toHex()
fetchedNode[0].user?.num = Int64(nodeInfoMessage.num)
fetchedNode[0].user?.longName = nodeInfoMessage.user.longName
fetchedNode[0].user?.shortName = nodeInfoMessage.user.shortName
fetchedNode[0].user?.role = Int32(nodeInfoMessage.user.role.rawValue)
fetchedNode[0].user?.hwModel = String(describing: nodeInfoMessage.user.hwModel).uppercased()
fetchedNode[0].user?.hwModelId = Int32(nodeInfoMessage.user.hwModel.rawValue)
/// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default
if nodeInfoMessage.user.hasIsUnmessagable {
fetchedNode[0].user?.unmessagable = nodeInfoMessage.user.isUnmessagable
} else {
let roles = [-1, 2, 4, 5, 6, 7, 10, 11]
let containsRole = roles.contains(Int(fetchedNode[0].user?.role ?? -1))
if containsRole {
fetchedNode[0].user?.unmessagable = true
} else {
fetchedNode[0].user?.unmessagable = false
}
}
if !nodeInfoMessage.user.publicKey.isEmpty {
fetchedNode[0].user!.pkiEncrypted = true
fetchedNode[0].user!.publicKey = nodeInfoMessage.user.publicKey
fetchedNode[0].user?.pkiEncrypted = true
fetchedNode[0].user?.publicKey = nodeInfoMessage.user.publicKey
}
Task {
Api().loadDeviceHardwareData { (hw) in
let dh = hw.first(where: { $0.hwModel == fetchedNode[0].user?.hwModelId ?? 0 })
fetchedNode[0].user!.hwDisplayName = dh?.displayName
fetchedNode[0].user?.hwDisplayName = dh?.displayName
}
}
}
@ -292,9 +391,14 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext)
fetchedNode[0].hopsAway = Int32(packet.hopStart - packet.hopLimit)
}
if fetchedNode[0].user == nil {
let newUser = createUser(num: Int64(truncatingIfNeeded: packet.from), context: context)
fetchedNode[0].user? = newUser
do {
let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context)
fetchedNode[0].user = newUser
} catch CoreDataError.invalidInput(let message) {
Logger.data.error("Error Creating a new Core Data UserEntity on an existing node (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)")
} catch {
Logger.data.error("Error Creating a new Core Data UserEntity on an existing node from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)")
}
}
do {
try context.save()
@ -358,12 +462,12 @@ func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext)
} else {
position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time)))
}
guard let mutablePositions = fetchedNode[0].positions!.mutableCopy() as? NSMutableOrderedSet else {
guard let mutablePositions = fetchedNode[0].positions?.mutableCopy() as? NSMutableOrderedSet else {
return
}
/// Don't save nearly the same position over and over. If the next position is less than 10 meters from the new position, delete the previous position and save the new one.
if mutablePositions.count > 0 && (position.precisionBits == 32 || position.precisionBits == 0) {
if let mostRecent = mutablePositions.lastObject as? PositionEntity, mostRecent.coordinate.distance(from: position.coordinate) < 15.0 {
if let mostRecent = mutablePositions.lastObject as? PositionEntity, mostRecent.coordinate.distance(from: position.coordinate) < 9.0 {
mutablePositions.remove(mostRecent)
}
} else if mutablePositions.count > 0 {
@ -528,6 +632,7 @@ func upsertDisplayConfigPacket(config: Config.DisplayConfig, nodeNum: Int64, ses
newDisplayConfig.displayMode = Int32(config.displaymode.rawValue)
newDisplayConfig.units = Int32(config.units.rawValue)
newDisplayConfig.headingBold = config.headingBold
newDisplayConfig.use12HClock = config.use12HClock
fetchedNode[0].displayConfig = newDisplayConfig
} else {
@ -539,6 +644,7 @@ func upsertDisplayConfigPacket(config: Config.DisplayConfig, nodeNum: Int64, ses
fetchedNode[0].displayConfig?.oledType = Int32(config.oled.rawValue)
fetchedNode[0].displayConfig?.displayMode = Int32(config.displaymode.rawValue)
fetchedNode[0].displayConfig?.units = Int32(config.units.rawValue)
fetchedNode[0].displayConfig?.use12HClock = config.use12HClock
fetchedNode[0].displayConfig?.headingBold = config.headingBold
}
if sessionPasskey != nil {
@ -687,7 +793,7 @@ func upsertNetworkConfigPacket(config: Config.NetworkConfig, nodeNum: Int64, ses
func upsertPositionConfigPacket(config: Config.PositionConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) {
let logString = String.localizedStringWithFormat("Positon config received: %@".localized, String(nodeNum))
let logString = String.localizedStringWithFormat("Position config received: %@".localized, String(nodeNum))
Logger.data.info("🗺️ \(logString, privacy: .public)")
let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest()
@ -1205,6 +1311,7 @@ func upsertMqttModuleConfigPacket(config: ModuleConfig.MQTTConfig, nodeNum: Int6
newMQTTConfig.jsonEnabled = config.jsonEnabled
newMQTTConfig.tlsEnabled = config.tlsEnabled
newMQTTConfig.mapReportingEnabled = config.mapReportingEnabled
newMQTTConfig.mapReportingShouldReportLocation = config.mapReportSettings.shouldReportLocation
newMQTTConfig.mapPositionPrecision = Int32(config.mapReportSettings.positionPrecision)
newMQTTConfig.mapPublishIntervalSecs = Int32(config.mapReportSettings.publishIntervalSecs)
fetchedNode[0].mqttConfig = newMQTTConfig
@ -1273,6 +1380,7 @@ func upsertRangeTestModuleConfigPacket(config: ModuleConfig.RangeTestConfig, nod
do {
try context.save()
Logger.data.info("💾 [RangeTestConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)")
} catch {
context.rollback()
let nsError = error as NSError
@ -1404,23 +1512,23 @@ func upsertTelemetryModuleConfigPacket(config: ModuleConfig.TelemetryConfig, nod
if !fetchedNode.isEmpty {
if fetchedNode[0].telemetryConfig == nil {
let newTelemetryConfig = TelemetryConfigEntity(context: context)
newTelemetryConfig.deviceUpdateInterval = Int32(config.deviceUpdateInterval)
newTelemetryConfig.environmentUpdateInterval = Int32(config.environmentUpdateInterval)
newTelemetryConfig.deviceUpdateInterval = safeInt32(from: config.deviceUpdateInterval)
newTelemetryConfig.environmentUpdateInterval = safeInt32(from: config.environmentUpdateInterval)
newTelemetryConfig.environmentMeasurementEnabled = config.environmentMeasurementEnabled
newTelemetryConfig.environmentScreenEnabled = config.environmentScreenEnabled
newTelemetryConfig.environmentDisplayFahrenheit = config.environmentDisplayFahrenheit
newTelemetryConfig.powerMeasurementEnabled = config.powerMeasurementEnabled
newTelemetryConfig.powerUpdateInterval = Int32(config.powerUpdateInterval)
newTelemetryConfig.powerUpdateInterval = safeInt32(from: config.powerUpdateInterval)
newTelemetryConfig.powerScreenEnabled = config.powerScreenEnabled
fetchedNode[0].telemetryConfig = newTelemetryConfig
} else {
fetchedNode[0].telemetryConfig?.deviceUpdateInterval = Int32(config.deviceUpdateInterval)
fetchedNode[0].telemetryConfig?.environmentUpdateInterval = Int32(config.environmentUpdateInterval)
fetchedNode[0].telemetryConfig?.deviceUpdateInterval = safeInt32(from: config.deviceUpdateInterval)
fetchedNode[0].telemetryConfig?.environmentUpdateInterval = safeInt32(from: config.environmentUpdateInterval)
fetchedNode[0].telemetryConfig?.environmentMeasurementEnabled = config.environmentMeasurementEnabled
fetchedNode[0].telemetryConfig?.environmentScreenEnabled = config.environmentScreenEnabled
fetchedNode[0].telemetryConfig?.environmentDisplayFahrenheit = config.environmentDisplayFahrenheit
fetchedNode[0].telemetryConfig?.powerMeasurementEnabled = config.powerMeasurementEnabled
fetchedNode[0].telemetryConfig?.powerUpdateInterval = Int32(config.powerUpdateInterval)
fetchedNode[0].telemetryConfig?.powerUpdateInterval = safeInt32(from: config.powerUpdateInterval)
fetchedNode[0].telemetryConfig?.powerScreenEnabled = config.powerScreenEnabled
}
if sessionPasskey != nil {

View file

@ -229,7 +229,8 @@
"images": [
"tlora-t3s3-epaper.svg"
],
"requiresDfu": true
"requiresDfu": true,
"hasInkHud": true
},
{
"hwModel": 17,
@ -604,7 +605,7 @@
"hwModelSlug": "HELTEC_WIRELESS_TRACKER",
"platformioTarget": "tracksenger",
"architecture": "esp32-s3",
"activelySupported": false,
"activelySupported": true,
"supportLevel": 3,
"displayName": "TrackSenger (small TFT)",
"requiresDfu": true,
@ -626,7 +627,7 @@
"hwModelSlug": "HELTEC_WIRELESS_TRACKER",
"platformioTarget": "tracksenger-oled",
"architecture": "esp32-s3",
"activelySupported": false,
"activelySupported": true,
"supportLevel": 3,
"displayName": "TrackSenger (big OLED)",
"partitionScheme": "8MB"
@ -872,5 +873,110 @@
"images": [
"thinknode_m2.svg"
]
},
{
"hwModel": 94,
"hwModelSlug": "HELTEC_MESH_POCKET",
"platformioTarget": "heltec-mesh-pocket-10000",
"architecture": "nrf52840",
"activelySupported": true,
"supportLevel": 1,
"displayName": "Heltec MeshPocket",
"tags": [
"Heltec"
],
"images": [
"heltec_mesh_pocket.svg"
],
"requiresDfu": true,
"hasInkHud": true
},
{
"hwModel": 95,
"hwModelSlug": "SEEED_SOLAR_NODE",
"platformioTarget": "seeed_solar_node",
"architecture": "nrf52840",
"activelySupported": true,
"supportLevel": 1,
"displayName": "Seeed SenseCAP Solar Node",
"tags": [
"Seeed"
],
"images": [
"seeed_solar.svg"
],
"requiresDfu": true
},
{
"hwModel": 99,
"hwModelSlug": "SEEED_WIO_TRACKER_L1",
"platformioTarget": "seeed_wio_tracker_L1",
"architecture": "nrf52840",
"activelySupported": true,
"supportLevel": 1,
"displayName": "Seeed Wio Tracker L1",
"tags": [
"Seeed"
],
"images": [
"wio_tracker_l1_case.svg"
],
"requiresDfu": true
},
{
"hwModel": 97,
"hwModelSlug": "CROWPANEL",
"platformioTarget": "elecrow-adv1-43-50-70-tft",
"architecture": "esp32-s3",
"activelySupported": true,
"supportLevel": 1,
"displayName": "Crowpanel Adv 4.3/5.0/7.0 TFT",
"tags": [
"Elecrow"
],
"requiresDfu": true,
"images": [
"crowpanel_5_0.svg",
"crowpanel_7_0.svg"
],
"partitionScheme": "16MB",
"hasMui": true
},
{
"hwModel": 97,
"hwModelSlug": "CROWPANEL",
"platformioTarget": "elecrow-adv-24-28-tft",
"architecture": "esp32-s3",
"activelySupported": true,
"supportLevel": 1,
"displayName": "Crowpanel Adv 2.4/2.8 TFT",
"tags": [
"Elecrow"
],
"requiresDfu": true,
"images": [
"crowpanel_2_4.svg",
"crowpanel_2_8.svg"
],
"partitionScheme": "16MB",
"hasMui": true
},
{
"hwModel": 97,
"hwModelSlug": "CROWPANEL",
"platformioTarget": "elecrow-adv-35-tft",
"architecture": "esp32-s3",
"activelySupported": true,
"supportLevel": 1,
"displayName": "Crowpanel Adv 3.5 TFT",
"tags": [
"Elecrow"
],
"requiresDfu": true,
"images": [
"crowpanel_3_5.svg"
],
"partitionScheme": "16MB",
"hasMui": true
}
]

View file

@ -16,7 +16,7 @@ struct BluetoothConnectionTip: Tip {
Text("Connected Radio")
}
var message: Text? {
Text("Shows information for the Lora radio connected via bluetooth. You can swipe left to disconnect the radio and long press start the live activity.")
Text("Shows information for the Lora radio connected via bluetooth. You can swipe left to disconnect the radio and long press to start the live activity.")
}
var image: Image? {
Image(systemName: "flipphone")

View file

@ -0,0 +1,37 @@
//
// Untitled.swift
// Meshtastic
//
// Created by Garth Vander Houwen on 6/19/25.
//
import TipKit
struct PersistentTip: TipViewStyle {
func makeBody(configuration: Configuration) -> some View {
VStack {
HStack(alignment: .top) {
if let image = configuration.image {
image
.font(.system(size: 42))
.foregroundColor(.accentColor)
.padding(.trailing, 5)
}
VStack(alignment: .leading) {
if let title = configuration.title {
title
.bold()
.font(.headline)
}
if let message = configuration.message {
message
.foregroundStyle(.secondary)
.font(.callout)
}
}
}
}
.frame(maxWidth: .infinity)
.backgroundStyle(.thinMaterial)
.padding()
}
}

View file

@ -32,9 +32,10 @@ struct Connect: View {
VStack {
List {
if bleManager.isSwitchedOn {
Section(header: Text("Connected Radio").font(.title)) {
Section {
if let connectedPeripheral = bleManager.connectedPeripheral, connectedPeripheral.peripheral.state == .connected {
TipView(BluetoothConnectionTip(), arrowEdge: .bottom)
.tipViewStyle(PersistentTip())
VStack(alignment: .leading) {
HStack {
VStack(alignment: .center) {
@ -76,47 +77,58 @@ struct Connect: View {
.foregroundColor(Color.gray)
.padding([.top])
.swipeActions {
Button(role: .destructive) {
if let connectedPeripheral = bleManager.connectedPeripheral,
connectedPeripheral.peripheral.state == .connected {
bleManager.disconnectPeripheral(reconnect: false)
if bleManager.allowDisconnect {
Button(role: .destructive) {
if let connectedPeripheral = bleManager.connectedPeripheral,
connectedPeripheral.peripheral.state == .connected {
bleManager.disconnectPeripheral(reconnect: false)
}
} label: {
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
}
} label: {
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
}
}
.contextMenu {
if node != nil {
Label("\(String(node!.num))", systemImage: "number")
Label("BLE RSSI \(connectedPeripheral.rssi)", systemImage: "cellularbars")
#if !targetEnvironment(macCatalyst)
Button {
if !liveActivityStarted {
#if canImport(ActivityKit)
Logger.services.info("Start live activity.")
startNodeActivity()
#endif
} else {
if bleManager.isSubscribed {
Button {
if !liveActivityStarted {
#if canImport(ActivityKit)
Logger.services.info("Stop live activity.")
endActivity()
#endif
Logger.services.info("Start live activity.")
startNodeActivity()
#endif
} else {
#if canImport(ActivityKit)
Logger.services.info("Stop live activity.")
endActivity()
#endif
}
} label: {
Label("Mesh Live Activity", systemImage: liveActivityStarted ? "stop" : "play")
}
} label: {
Label("Mesh Live Activity", systemImage: liveActivityStarted ? "stop" : "play")
}
#endif
Text("Num: \(String(node!.num))")
Text("Short Name: \(node?.user?.shortName ?? "?")")
Text("Long Name: \(node?.user?.longName?.addingVariationSelectors ?? "Unknown".localized)")
Text("BLE RSSI: \(connectedPeripheral.rssi)")
Button {
if !bleManager.sendShutdown(fromUser: node!.user!, toUser: node!.user!, adminIndex: node!.myInfo!.adminIndex) {
Logger.mesh.error("Shutdown Failed")
if bleManager.allowDisconnect {
Button(role: .destructive) {
if let connectedPeripheral = bleManager.connectedPeripheral,
connectedPeripheral.peripheral.state == .connected {
bleManager.disconnectPeripheral(reconnect: false)
}
} label: {
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
}
Button(role: .destructive) {
if !bleManager.sendShutdown(fromUser: node!.user!, toUser: node!.user!) {
Logger.mesh.error("Shutdown Failed")
}
} label: {
Label("Power Off", systemImage: "power")
} label: {
Label("Power Off", systemImage: "power")
}
}
}
}
@ -132,7 +144,6 @@ struct Connect: View {
}
}
} else {
if bleManager.isConnecting {
HStack {
Image(systemName: "antenna.radiowaves.left.and.right")

View file

@ -32,47 +32,64 @@ import Foundation
import SwiftUI
struct SignalStrengthIndicator: View {
let signalStrength: BLESignalStrength
// Accessibility: VoiceOver description
private var accessibilityDescription: String {
switch signalStrength {
case .weak:
return "Signal strength weak".localized
case .normal:
return "Signal strength normal".localized
case .strong:
return "Signal strength strong".localized
}
}
var body: some View {
HStack {
ForEach(0..<3) { bar in
RoundedRectangle(cornerRadius: 3)
.divided(amount: (CGFloat(bar) + 1) / CGFloat(3))
.fill(getColor().opacity(bar <= signalStrength.rawValue ? 1 : 0.3))
.frame(width: 8, height: 40)
}
}
}
let signalStrength: BLESignalStrength
private func getColor() -> Color {
switch signalStrength {
case .weak:
return Color.red
case .normal:
return Color.yellow
case .strong:
return Color.green
}
}
var body: some View {
Group {
HStack {
ForEach(0..<3) { bar in
RoundedRectangle(cornerRadius: 3)
.divided(amount: (CGFloat(bar) + 1) / CGFloat(3))
.fill(getColor().opacity(bar <= signalStrength.rawValue ? 1 : 0.3))
.frame(width: 8, height: 40)
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Signal strength".localized)
.accessibilityValue(accessibilityDescription)
}
private func getColor() -> Color {
switch signalStrength {
case .weak:
return Color.red
case .normal:
return Color.yellow
case .strong:
return Color.green
}
}
}
struct Divided<S: Shape>: Shape {
var amount: CGFloat // Should be in range 0...1
var shape: S
func path(in rect: CGRect) -> Path {
shape.path(in: rect.divided(atDistance: amount * rect.height, from: .maxYEdge).slice)
}
var amount: CGFloat // Should be in range 0...1
var shape: S
func path(in rect: CGRect) -> Path {
shape.path(in: rect.divided(atDistance: amount * rect.height, from: .maxYEdge).slice)
}
}
extension Shape {
func divided(amount: CGFloat) -> Divided<Self> {
return Divided(amount: amount, shape: self)
}
func divided(amount: CGFloat) -> Divided<Self> {
return Divided(amount: amount, shape: self)
}
}
enum BLESignalStrength: Int {
case weak = 0
case normal = 1
case strong = 2
case weak = 0
case normal = 1
case strong = 2
}

View file

@ -13,69 +13,101 @@ struct BatteryCompact: View {
var color: Color
var body: some View {
// Group the battery icon and label in a single accessible container
HStack(alignment: .center, spacing: 0) {
if let batteryLevel {
if batteryLevel == 100 {
Image(systemName: "battery.100.bolt")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
} else if batteryLevel < 100 && batteryLevel > 74 {
Image(systemName: "battery.75")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
} else if batteryLevel < 75 && batteryLevel > 49 {
Image(systemName: "battery.50")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
} else if batteryLevel < 50 && batteryLevel > 14 {
Image(systemName: "battery.25")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
} else if batteryLevel < 15 && batteryLevel > 0 {
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
} else if batteryLevel == 0 {
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(.red)
.symbolRenderingMode(.multicolor)
} else if batteryLevel > 100 {
// Check for plugged in state
let isPluggedIn = batteryLevel > 100
let isCharging = batteryLevel == 100
// Battery icon selection based on level
if isPluggedIn {
Image(systemName: "powerplug")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true) // Hide from VoiceOver since container will handle it
} else if isCharging {
Image(systemName: "battery.100.bolt")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
} else if batteryLevel > 74 {
Image(systemName: "battery.75")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
} else if batteryLevel > 49 {
Image(systemName: "battery.50")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
} else if batteryLevel > 14 {
Image(systemName: "battery.25")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
} else if batteryLevel > 0 {
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
} else {
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(.red)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
}
} else {
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
}
if let batteryLevel {
if batteryLevel > 100 {
// Battery text label
if isPluggedIn {
Text("PWD")
.foregroundStyle(.secondary)
.font(font)
} else if batteryLevel == 100 {
.accessibilityHidden(true)
} else if isCharging {
Text("CHG")
.foregroundStyle(.secondary)
.font(font)
.accessibilityHidden(true)
} else {
Text(verbatim: "\(batteryLevel.formatted(.number.precision(.fractionLength(0))))%")
.foregroundStyle(.secondary)
.font(font)
.accessibilityHidden(true)
}
} else {
// Unknown battery state
Image(systemName: "battery.0")
.font(iconFont)
.foregroundColor(color)
.symbolRenderingMode(.multicolor)
.accessibilityHidden(true)
Text(verbatim: "?")
.foregroundStyle(.secondary)
.font(font)
.accessibilityHidden(true)
}
}
// Setup container-level accessibility for VoiceOver
.accessibilityElement(children: .ignore)
.accessibilityLabel(NSLocalizedString("Battery Level", comment: "VoiceOver label for battery gauge"))
// Set appropriate value based on the battery state using a computed property
.accessibilityValue(batteryLevel.map { level in
if level > 100 {
// Plugged in - same as PWD visual indicator
return "Plugged in".localized
} else if level == 100 {
// Charging - same as CHG visual indicator
return "Charging".localized
} else {
// Normal battery level
return String(format: NSLocalizedString("Battery Level %", comment: "VoiceOver value for battery level"), Int(level))
}
} ?? "Unknown")
}
}

View file

@ -18,18 +18,20 @@ struct BatteryGauge: View {
let deviceMetrics = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0"))
let mostRecent = deviceMetrics?.lastObject as? TelemetryEntity
let batteryLevel = Double(mostRecent?.batteryLevel ?? 0)
// For VoiceOver purposes, detect when device is plugged in (battery > 100%)
let isPluggedIn = (mostRecent?.batteryLevel ?? 0) > 100
// Use a capped battery level for UI display
let batteryLevel = Double(min(100, mostRecent?.batteryLevel ?? 0))
VStack {
if batteryLevel > 100.0 {
// Plugged in
Image(systemName: "powerplug")
.font(.largeTitle)
.foregroundColor(.accentColor)
.symbolRenderingMode(.hierarchical)
if isPluggedIn {
// Use a completely standalone view for the plugged in state
// to avoid any VoiceOver confusion
PluggedInIndicator()
} else {
let gradient = Gradient(colors: [.red, .orange, .green])
Gauge(value: batteryLevel, in: minValue...maxValue) {
// Accessibility for battery gauge
if batteryLevel >= 0.0 && batteryLevel < 10 {
Label("Battery Level %", systemImage: "battery.0")
} else if batteryLevel >= 10.0 && batteryLevel < 25.00 {
@ -50,6 +52,8 @@ struct BatteryGauge: View {
Text(Int(batteryLevel), format: .percent)
}
}
.accessibilityLabel(NSLocalizedString("Battery Level", comment: "VoiceOver label for battery gauge"))
.accessibilityValue(String(format: NSLocalizedString("Battery Level %", comment: "VoiceOver value for battery level"), Int(batteryLevel)))
.tint(gradient)
.gaugeStyle(.accessoryCircular)
}
@ -63,6 +67,23 @@ struct BatteryGauge: View {
}
}
/// A dedicated view for showing a device is plugged in
/// With proper VoiceOver support that matches the visual indication
struct PluggedInIndicator: View {
var body: some View {
// This view is isolated from any battery measurement
// to ensure VoiceOver doesn't pick up any percentages
Image(systemName: "powerplug")
.font(.largeTitle)
.foregroundColor(.accentColor)
.symbolRenderingMode(.hierarchical)
// Override the accessibility to ensure correct VoiceOver announcement
.accessibilityElement(children: .ignore)
.accessibilityLabel("Battery Level".localized)
.accessibilityValue("Plugged in".localized)
}
}
struct BatteryGauge_Previews: PreviewProvider {
static var previews: some View {
VStack {

View file

@ -0,0 +1,35 @@
//
// ChannelLock.swift
// Meshtastic
//
// Copyright(c) Garth Vander Houwen 6/22/25.
//
import SwiftUI
struct ChannelLock: View {
@ObservedObject var channel: ChannelEntity
var body: some View {
/// Unencrypted - using no key at all or a known 1 byte key
if channel.psk?.hexDescription.count ?? 0 < 3 {
let preciseLoction = 17...32
// Using precise location and have MQTT uplink enabled
if channel.uplinkEnabled && preciseLoction ~= (Int(channel.positionPrecision)) {
Image(systemName: "lock.open.trianglebadge.exclamationmark.fill")
.foregroundColor(.red)
// Using precise location
} else if preciseLoction ~= (Int(channel.positionPrecision)) {
Image(systemName: "lock.open.fill")
.foregroundColor(.red)
// Just unencrypted without any location or MQTT
} else {
Image(systemName: "lock.open.fill")
.foregroundColor(.yellow)
}
} else {
Image(systemName: "lock.fill")
.foregroundColor(.green)
}
}
}

View file

@ -21,22 +21,46 @@ struct ConnectedDevice: View {
if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly {
if bluetoothOn {
if deviceConnected {
if mqttUplinkEnabled || mqttDownlinkEnabled {
MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic)
}
Image(systemName: "antenna.radiowaves.left.and.right.circle.fill")
.imageScale(.large)
.foregroundColor(.green)
.symbolRenderingMode(.hierarchical)
Text(name.addingVariationSelectors).font(name.isEmoji() ? .title : .callout).foregroundColor(.gray)
// Create an HStack for connected state with proper accessibility
HStack {
if mqttUplinkEnabled || mqttDownlinkEnabled {
MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic)
.accessibilityHidden(true)
}
Image(systemName: "antenna.radiowaves.left.and.right.circle.fill")
.imageScale(.large)
.foregroundColor(.green)
.symbolRenderingMode(.hierarchical)
.accessibilityHidden(true)
Text(name.addingVariationSelectors)
.font(name.isEmoji() ? .title : .callout)
.foregroundColor(.gray)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Connected to Bluetooth device".localized + ", " + name.formatNodeNameForVoiceOver())
} else {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.imageScale(.medium)
.foregroundColor(.red)
.symbolRenderingMode(.hierarchical)
// Create a container for disconnected state
HStack {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.imageScale(.medium)
.foregroundColor(.red)
.symbolRenderingMode(.hierarchical)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("No Bluetooth device connected".localized)
}
} else {
Text("Bluetooth is off").font(.subheadline).foregroundColor(.red)
// Create a container for Bluetooth off state
HStack {
Text("bluetooth.off".localized)
.font(.subheadline)
.foregroundColor(.red)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("bluetooth.off".localized)
}
}
}

View file

@ -0,0 +1,97 @@
//
// ChannelHelp.swift
// Meshtastic
//
// Copyright(c) Garth Vander Houwen 6/18/25.
//
import SwiftUI
struct ChannelsHelp: View {
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
@Environment(\.dismiss) private var dismiss
var body: some View {
ScrollView {
Label("Channels Help", systemImage: "questionmark.circle")
.font(.title)
.padding(.vertical)
VStack(alignment: .leading) {
HStack {
CircleText(text: String(0), color: .accentColor)
.brightness(0.2)
.offset(y: -10)
Text("A channel index of 0 indicates the primary channel where broadcast packets are sent from. Location data is broadcast from the first channel where it is enabled with firmware 2.7 forward.")
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom)
.padding(.leading, 7)
}
HStack {
Image(systemName: "lock.fill")
.padding(.leading)
.padding(.trailing, 7)
.foregroundColor(Color.green)
.font(.title)
Text("A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key.")
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom)
}
HStack {
Image(systemName: "lock.open.fill")
.padding(.leading)
.foregroundColor(Color.yellow)
.font(.title)
Text("A yellow open lock lock means the channel is not securely encrypted but it not used for precise location data, it uses either no key at all or a 1 byte known key.")
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom)
}
HStack {
Image(systemName: "lock.open.fill")
.padding(.leading)
.foregroundColor(Color.red)
.font(.title)
Text("A red open lock means the channel is not securely encrypted and is used for precise location data, it uses either no key at all or a 1 byte known key.")
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom)
}
HStack {
Image(systemName: "lock.open.trianglebadge.exclamationmark.fill")
.padding(.leading)
.symbolRenderingMode(.multicolor)
.foregroundColor(Color.red)
.font(.title)
Text("A red open lock with a warning means the channel is not securely encrypted and is used for precise location data which is being uplinked to the internet via MQTT, it uses either no key at all or a 1 byte known key.")
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom)
}
}
#if targetEnvironment(macCatalyst)
Spacer()
Button {
dismiss()
} label: {
Label("Close", systemImage: "xmark")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding(.bottom)
#endif
}
.frame(minHeight: 0, maxHeight: .infinity, alignment: .leading)
.padding()
.presentationDetents([.large])
.presentationContentInteraction(.scrolls)
.presentationDragIndicator(.visible)
.presentationBackgroundInteraction(.enabled(upThrough: .large))
}
}
struct ChannelHelpPreviews: PreviewProvider {
static var previews: some View {
VStack {
ChannelsHelp()
}
}
}

View file

@ -51,6 +51,20 @@ struct LoRaSignalStrengthMeter_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
VStack {
VStack {
// Good
LoRaSignalStrengthMeter(snr: -10, rssi: -100, preset: ModemPresets.longFast, compact: true)
.padding(.bottom)
// Fair
LoRaSignalStrengthMeter(snr: -9.5, rssi: -119, preset: ModemPresets.longFast, compact: true)
.padding(.bottom)
// Bad
LoRaSignalStrengthMeter(snr: -12.75, rssi: -139, preset: ModemPresets.longFast, compact: true)
.padding(.bottom)
// None
LoRaSignalStrengthMeter(snr: -26.0, rssi: -128, preset: ModemPresets.longFast, compact: true)
.padding(.bottom)
}.padding()
HStack {
// Good
LoRaSignalStrengthMeter(snr: -1, rssi: -114, preset: ModemPresets.longFast, compact: false)
@ -85,16 +99,5 @@ struct LoRaSignalStrengthMeter_Previews: PreviewProvider {
}
.padding(.top)
}
VStack {
// Good
LoRaSignalStrengthMeter(snr: -10, rssi: -100, preset: ModemPresets.longFast, compact: true)
// Fair
LoRaSignalStrengthMeter(snr: -9.5, rssi: -119, preset: ModemPresets.longFast, compact: true)
// Bad
LoRaSignalStrengthMeter(snr: -12.75, rssi: -139, preset: ModemPresets.longFast, compact: true)
// None
LoRaSignalStrengthMeter(snr: -26.0, rssi: -128, preset: ModemPresets.longFast, compact: true)
}
}
}

View file

@ -11,27 +11,22 @@ struct MeshtasticLogo: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
#if targetEnvironment(macCatalyst)
VStack {
Image("logo-white")
.resizable()
.renderingMode(.template)
.foregroundColor(.accentColor)
.scaledToFit()
}
.padding(.bottom, 5)
.padding(.top, 5)
.offset(x: -15)
#else
VStack {
Image(colorScheme == .dark ? "logo-white" : "logo-black")
.resizable()
.renderingMode(.template)
.scaledToFit()
}
.padding(.bottom, 5)
.offset(x: -15)
#endif
}
}

View file

@ -0,0 +1,113 @@
//
// RateLimitCountdownView.swift
// Meshtastic
//
// Created by Jake Bordens on 5/5/25.
//
import SwiftUI
// This class provides a rate limited button.
// Provide a key to differentiate which action is rate-limited
// This allows you to keep different rate limits for different action
// Rate limits are stored in a RateLimitStorage singleton, but do not persist
public struct RateLimitedButton<Content: View>: View {
typealias Builder = ((percentComplete: Double, secondsRemaining: TimeInterval)?) -> Content
let key: String
@StateObject var storage = RateLimitStorage.shared
let rateLimit: TimeInterval
let content: Builder
let action: () -> Void
init(key: String, rateLimit: TimeInterval, action: @escaping () -> Void, @ViewBuilder label: @escaping Builder) {
self.key = key
self.rateLimit = rateLimit
self.content = label
self.action = action
}
public var body: some View {
let percentRemaining = storage.rateLimitRemainingPercentage(forKey: key)
let secondsRemaining = storage.rateLimitSecondsRemaining(forKey: key)
if percentRemaining > 0.0 {
content((percentRemaining, secondsRemaining))
} else {
Button {
storage.actionOccured(forKey: key, rateLimit: rateLimit)
action()
} label: {
content(nil)
}
}
}
}
// To store the time an action occured (name by a key) and the time limit
// Does not persist across app launches
class RateLimitStorage: ObservableObject {
private struct RateLimiter {
var actionOccuredTimestamp: Date
var rateLimitSeconds: TimeInterval
var rateLimitExpires: Date {
return actionOccuredTimestamp.addingTimeInterval(rateLimitSeconds)
}
}
static var shared: RateLimitStorage = RateLimitStorage() // Singleton instance
private var rateLimits = [String: RateLimiter]()
private var timer: Timer?
func actionOccured(forKey key: String, rateLimit: TimeInterval) {
let now = Date()
if let existingRateLimit = rateLimits[key] {
if existingRateLimit.rateLimitExpires > now.addingTimeInterval(rateLimit) {
// We have an existing rate limit that is larger than the one being requested
// Ignore
return
}
}
self.objectWillChange.send()
rateLimits[key] = RateLimiter(actionOccuredTimestamp: now, rateLimitSeconds: rateLimit)
startTimerIfNecessary()
}
func rateLimitRemainingPercentage(forKey: String) -> Double {
guard let rateLimit = rateLimits[forKey] else {
return 0.0
}
let percent = (rateLimit.rateLimitExpires.timeIntervalSinceNow) / rateLimit.rateLimitSeconds
return min(1.0, max(percent, 0.0))
}
func rateLimitSecondsRemaining(forKey: String) -> TimeInterval {
guard let rateLimit = rateLimits[forKey] else {
return 0.0
}
return rateLimit.rateLimitExpires.timeIntervalSinceNow
}
func startTimerIfNecessary() {
// Timer exists, don't create one
guard timer == nil else { return }
// Create the timer
self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
self.objectWillChange.send()
// Determine if we can clean up the dictionary and stop the timer.
let maxExpiration = self.rateLimits.values.map { $0.rateLimitExpires }.max() ?? .distantPast
if maxExpiration.timeIntervalSinceNow < 0 {
// All rateLimits are in the past. Stop and clean up
self.timer?.invalidate()
self.timer = nil
self.rateLimits.removeAll()
}
}
}
}

View file

@ -12,19 +12,28 @@ struct SecureInput: View {
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
@Binding private var text: String
@Binding private var isValid: Bool
@State var isSecure: Bool = true
private var title: String
init(_ title: String, text: Binding<String>, isValid: Binding<Bool>) {
// Local state to store the value of iSSecure, or optionally a binding
private var isSecureBinding: Binding<Bool>?
@State private var isSecureLocal: Bool = true
private var isSecure: Binding<Bool> {
// Use the binding if we have one, otherwise fallback to the local state variable
isSecureBinding ?? $isSecureLocal
}
init(_ title: String, text: Binding<String>, isValid: Binding<Bool>, isSecure: Binding<Bool>? = nil) {
self.title = title
self._text = text
self._isValid = isValid
self.isSecureBinding = isSecure
}
var body: some View {
ZStack(alignment: .trailing) {
Group {
if isSecure {
if isSecure.wrappedValue {
SecureField(title, text: $text)
.font(idiom == .phone ? .caption : .callout)
.allowsTightening(true)
@ -51,9 +60,9 @@ struct SecureInput: View {
if !text.isEmpty {
Button(action: {
isSecure.toggle()
isSecure.wrappedValue.toggle()
}) {
Image(systemName: self.isSecure ? "eye.slash" : "eye")
Image(systemName: self.isSecure.wrappedValue ? "eye.slash" : "eye")
.accentColor(.secondary)
}.buttonStyle(BorderlessButtonStyle())
}

View file

@ -21,11 +21,17 @@ struct ChannelList: View {
var channelSelection: ChannelEntity?
@State private var isPresentingDeleteChannelMessagesConfirm: Bool = false
@State private var isPresentingTraceRouteSentAlert = false
@State private var showingHelp = false
var restrictedChannels = ["gpio", "mqtt", "serial", "admin"]
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \ChannelEntity.index, ascending: true)],
predicate: nil,
animation: .default
) private var channels: FetchedResults<ChannelEntity>
@ViewBuilder
private func makeChannelRow(
myInfo: MyInfoEntity,
@ -52,6 +58,7 @@ struct ChannelList: View {
VStack(alignment: .leading) {
HStack {
ChannelLock(channel: channel)
if channel.name?.isEmpty ?? false {
if channel.role == 1 {
Text(String("PrimaryChannel").camelCaseToWords())
@ -87,6 +94,9 @@ struct ChannelList: View {
.foregroundColor(.secondary)
}
}
if channel.mute {
Image(systemName: "bell.slash")
}
}
if channel.allPrivateMessages.count > 0 {
@ -103,7 +113,7 @@ struct ChannelList: View {
var body: some View {
VStack {
// Display Contacts for the rest of the non admin channels
if let node, let myInfo = node.myInfo, let channels = myInfo.channels?.array as? [ChannelEntity] {
if let node, let myInfo = node.myInfo {
List(selection: $channelSelection) {
ForEach(channels) { (channel: ChannelEntity) in
if !restrictedChannels.contains(channel.name?.lowercased() ?? "") {
@ -119,7 +129,7 @@ struct ChannelList: View {
}
}
Button {
channel.mute = !channel.mute
channel.mute.toggle()
do {
let adminMessageId = bleManager.saveChannel(channel: channel.protoBuf, fromUser: node.user!, toUser: node.user!)
if adminMessageId > 0 {
@ -152,8 +162,31 @@ struct ChannelList: View {
}
.padding([.top, .bottom])
.listStyle(.plain)
.navigationTitle("Channels")
}
}
.navigationTitle("Channels")
.sheet(isPresented: $showingHelp) {
ChannelsHelp()
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.safeAreaInset(edge: .bottom, alignment: .leading) {
HStack {
Button(action: {
withAnimation {
showingHelp = !showingHelp
}
}) {
Image(systemName: !showingHelp ? "questionmark.circle" : "questionmark.circle.fill")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
}
.controlSize(.regular)
.padding(5)
}
.padding(.bottom, 5)
}
}

View file

@ -33,8 +33,15 @@ struct ChannelMessageList: View {
ZStack(alignment: .bottomTrailing) {
ScrollView {
LazyVStack {
ForEach(channel.allPrivateMessages) { (message: MessageEntity) in
ForEach(Array(channel.allPrivateMessages.enumerated()), id: \.element.id) { index, message in
// Get the previous message, if it exists
let previousMessage = index > 0 ? channel.allPrivateMessages[index - 1] : nil
let currentUser: Bool = (Int64(preferredPeripheralNum) == message.fromUser?.num ? true : false)
if message.displayTimestamp(aboveMessage: previousMessage) {
Text(message.timestamp.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundColor(.gray)
}
if message.replyID > 0 {
let messageReply = channel.allPrivateMessages.first(where: { $0.messageId == message.replyID })
HStack {
@ -44,7 +51,6 @@ struct ChannelMessageList: View {
messageToHighlight = messageNum
}
scrollView.scrollTo(messageNum, anchor: .center)
// Reset highlight after delay
Task {
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
@ -130,12 +136,11 @@ struct ChannelMessageList: View {
Spacer(minLength: 50)
}
}
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(.blue, lineWidth: 2)
.opacity(((messageToHighlight == message.messageId) || (replyMessageId == message.messageId)) ? 1 : 0)
}
// .overlay {
// RoundedRectangle(cornerRadius: 18)
// .stroke(.blue, lineWidth: 2)
// .opacity(((messageToHighlight == message.messageId) || (replyMessageId == message.messageId)) ? 1 : 0)
// }
.padding([.leading, .trailing])
.frame(maxWidth: .infinity)
.id(message.messageId)
@ -175,24 +180,24 @@ struct ChannelMessageList: View {
}
.scrollDismissesKeyboard(.interactively)
.onFirstAppear {
// Find first unread message
if let firstUnreadMessageId = channel.allPrivateMessages.first(where: { !$0.read })?.messageId {
if channel.unreadMessages == 0 {
withAnimation {
scrollView.scrollTo(firstUnreadMessageId, anchor: .top)
showScrollToBottomButton = true
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
hasReachedBottom = true
}
} else {
// If no unread messages, scroll to bottom
withAnimation {
scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom)
hasReachedBottom = true
if let firstUnreadMessageId = channel.allPrivateMessages.first(where: { !$0.read })?.messageId {
withAnimation {
scrollView.scrollTo(firstUnreadMessageId, anchor: .top)
showScrollToBottomButton = true
}
}
}
gotFirstUnreadMessage = true
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in
withAnimation {
scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom)
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
hasReachedBottom = true
showScrollToBottomButton = false
}
@ -200,7 +205,7 @@ struct ChannelMessageList: View {
.onChange(of: channel.allPrivateMessages) {
if hasReachedBottom {
withAnimation {
scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom)
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
}
} else {
showScrollToBottomButton = true

View file

@ -17,6 +17,10 @@ struct MessageText: View {
let tapBackDestination: MessageDestination
let isCurrentUser: Bool
let onReply: () -> Void
// State for handling channel URL sheet
@State private var saveChannels = false
@State private var channelSettings: String?
@State private var addChannels = false
@State private var isShowingDeleteConfirmation = false
@ -83,6 +87,60 @@ struct MessageText: View {
onReply: onReply
)
}
.environment(\.openURL, OpenURLAction { url in
channelSettings = nil
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
// Handle contact URL
ContactURLHandler.handleContactUrl(url: url, bleManager: BLEManager.shared)
return .handled // Prevent default browser opening
} else if url.absoluteString.lowercased().contains("meshtastic.org/e/") {
// Handle channel URL
let components = url.absoluteString.components(separatedBy: "#")
guard !components.isEmpty, let lastComponent = components.last else {
Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)")
return .discarded
}
self.addChannels = Bool(url.query?.contains("add=true") ?? false)
guard let lastComponent = components.last else {
Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)")
self.channelSettings = nil
return .discarded
}
self.channelSettings = lastComponent.components(separatedBy: "?").first ?? ""
Logger.services.debug("Add Channel: \(self.addChannels, privacy: .public)")
self.saveChannels = true
Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)")
return .handled // Prevent default browser opening
}
return .systemAction // Open other URLs in browser
})
// Display sheet for channel settings
.sheet(isPresented: Binding(
get: {
saveChannels && !(channelSettings == nil)
},
set: { newValue in
saveChannels = newValue
if !newValue {
channelSettings = nil
}
}
)) {
SaveChannelQRCode(
channelSetLink: channelSettings ?? "Empty Channel URL",
addChannels: addChannels,
bleManager: BLEManager.shared
)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.confirmationDialog(
"Are you sure you want to delete this message?",
isPresented: $isShowingDeleteConfirmation,

View file

@ -64,6 +64,7 @@ struct Messages: View {
}
TipView(MessagesTip(), arrowEdge: .top)
.tipViewStyle(PersistentTip())
}
.navigationTitle("Messages")
.navigationBarTitleDisplayMode(.large)

View file

@ -6,6 +6,7 @@ struct RequestPositionButton: View {
var body: some View {
Button(action: action) {
Image(systemName: "mappin.and.ellipse")
.accessibilityLabel("Position Exchange Requested".localized)
.symbolRenderingMode(.hierarchical)
.imageScale(.large)
.foregroundColor(.accentColor)

View file

@ -39,8 +39,9 @@ struct TextMessageField: View {
} label: {
Image(systemName: "x.circle.fill")
}
Text("Replying to a message")
Text("Reply")
}
.padding(.top)
}
ZStack {

View file

@ -6,6 +6,8 @@ struct TextMessageSize: View {
var body: some View {
ProgressView("\("Bytes".localized): \(totalBytes) / \(maxbytes)", value: Double(totalBytes), total: Double(maxbytes))
.accessibilityLabel(NSLocalizedString("Message Size", comment: "VoiceOver label for message size"))
.accessibilityValue(String(format: NSLocalizedString("Bytes Used", comment: "VoiceOver value for bytes used"), totalBytes, maxbytes))
.frame(width: 130)
.padding(5)
.font(.subheadline)

View file

@ -39,18 +39,6 @@ struct UserList: View {
roleFilter
]}
@FetchRequest(
sortDescriptors: [NSSortDescriptor(key: "lastMessage", ascending: false),
NSSortDescriptor(key: "userNode.favorite", ascending: false),
NSSortDescriptor(key: "pkiEncrypted", ascending: false),
NSSortDescriptor(key: "userNode.lastHeard", ascending: false),
NSSortDescriptor(key: "longName", ascending: true)],
predicate: NSPredicate(
format: "userNode.ignored == false && longName != '' AND NOT (userNode.viaMqtt == YES AND userNode.hopsAway > 0)"
), animation: .default
)
var users: FetchedResults<UserEntity>
@Binding var node: NodeInfoEntity?
@Binding var userSelection: UserEntity?
@ -60,8 +48,23 @@ struct UserList: View {
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current)
let dateFormatString = (localeDateFormat ?? "MM/dd/YY")
VStack {
List(selection: $userSelection) {
ForEach(users) { (user: UserEntity) in
FilteredUserList(
searchText: searchText,
viaLora: viaLora,
viaMqtt: viaMqtt,
isOnline: isOnline,
isPkiEncrypted: isPkiEncrypted,
isFavorite: isFavorite,
isIgnored: isIgnored,
isEnvironment: isEnvironment,
distanceFilter: distanceFilter,
maxDistance: maxDistance,
hopsAway: hopsAway,
roleFilter: roleFilter,
deviceRoles: deviceRoles,
userSelection: $userSelection
) { users in
List(users, selection: $userSelection) { (user: UserEntity) in
let mostRecent = user.messageList.last
let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 ))))
let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0
@ -134,18 +137,17 @@ struct UserList: View {
.frame(height: 62)
.contextMenu {
Button {
if node != nil && !(user.userNode?.favorite ?? false) {
let success = bleManager.setFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
if success {
user.userNode?.favorite = !(user.userNode?.favorite ?? true)
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
Logger.data.info("Favorited a node")
}
} else {
let success = bleManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
if success {
user.userNode?.favorite = !(user.userNode?.favorite ?? true)
Logger.data.info("Un Favorited a node")
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
Logger.data.info("Unfavorited a node")
}
}
context.refresh(user, mergeChanges: true)
@ -156,7 +158,7 @@ struct UserList: View {
Logger.data.error("Save Node Favorite Error")
}
} label: {
Label((user.userNode?.favorite ?? false) ? "Un-Favorite" : "Favorite", systemImage: (user.userNode?.favorite ?? false) ? "star.slash.fill" : "star.fill")
Label((user.userNode?.favorite ?? false) ? "Un-Favorite" : "Favorite", systemImage: (user.userNode?.favorite ?? false) ? "star.slash.fill" : "star.fill")
}
Button {
user.mute = !user.mute
@ -169,7 +171,7 @@ struct UserList: View {
} label: {
Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash")
}
if user.messageList.count > 0 {
if user.messageList.count > 0 {
Button(role: .destructive) {
isPresentingDeleteUserMessagesConfirm = true
userSelection = user
@ -192,66 +194,15 @@ struct UserList: View {
}
}
}
.listStyle(.plain)
.navigationTitle(String.localizedStringWithFormat("Contacts (%@)", String(users.count)))
}
.listStyle(.plain)
.navigationTitle(String.localizedStringWithFormat("Contacts (%@)".localized, String(users.count == 0 ? 0 : users.count)))
.sheet(isPresented: $editingFilters) {
NodeListFilter(filterTitle: "Contact Filters", viaLora: $viaLora, viaMqtt: $viaMqtt, isOnline: $isOnline, isPkiEncrypted: $isPkiEncrypted, isFavorite: $isFavorite, isIgnored: $isIgnored, isEnvironment: $isEnvironment, distanceFilter: $distanceFilter, maximumDistance: $maxDistance, hopsAway: $hopsAway, roleFilter: $roleFilter, deviceRoles: $deviceRoles)
}
.sheet(isPresented: $showingHelp) {
DirectMessagesHelp()
}
.onChange(of: searchText) {
Task {
await searchUserList()
}
}
.onChange(of: viaLora) {
if !viaLora && !viaMqtt {
viaMqtt = true
}
Task {
await searchUserList()
}
}
.onChange(of: viaMqtt) {
if !viaLora && !viaMqtt {
viaLora = true
}
Task {
await searchUserList()
}
}
.onChange(of: [deviceRoles]) {
Task {
await searchUserList()
}
}
.onChange(of: hopsAway) {
Task {
await searchUserList()
}
}
.onChange(of: [boolFilters]) {
Task {
await searchUserList()
}
}
.onChange(of: maxDistance) {
Task {
await searchUserList()
}
}
.onChange(of: isPkiEncrypted) {
Task {
await searchUserList()
}
}
.onAppear {
Task {
await searchUserList()
}
}
.safeAreaInset(edge: .bottom, alignment: .leading) {
HStack {
Button(action: {
@ -282,36 +233,60 @@ struct UserList: View {
.padding(5)
}
.padding(.bottom, 5)
.padding(.bottom, 5)
.searchable(text: $searchText, placement: users.count > 10 ? .navigationBarDrawer(displayMode: .always) : .automatic, prompt: "Find a contact")
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Find a contact")
.disableAutocorrection(true)
.scrollDismissesKeyboard(.immediately)
}
}
private func searchUserList() async {
}
/// Case Insensitive Search Text Predicates
let searchPredicates = ["userId", "numString", "hwModel", "hwDisplayName", "longName", "shortName"].map { property in
return NSPredicate(format: "%K CONTAINS[c] %@", property, searchText)
}
/// Create a compound predicate using each text search preicate as an OR
let textSearchPredicate = NSCompoundPredicate(type: .or, subpredicates: searchPredicates)
/// Create an array of predicates to hold our AND predicates
struct FilteredUserList<Content: View>: View {
@FetchRequest var fetchRequest: FetchedResults<UserEntity>
let content: (FetchedResults<UserEntity>) -> Content
var body: some View {
content(fetchRequest)
}
init(
searchText: String,
viaLora: Bool,
viaMqtt: Bool,
isOnline: Bool,
isPkiEncrypted: Bool,
isFavorite: Bool,
isIgnored: Bool,
isEnvironment: Bool,
distanceFilter: Bool,
maxDistance: Double,
hopsAway: Double,
roleFilter: Bool,
deviceRoles: Set<Int>,
userSelection: Binding<UserEntity?>,
@ViewBuilder content: @escaping (FetchedResults<UserEntity>) -> Content
) {
self.content = content
// Build predicates based on filter variables
var predicates: [NSPredicate] = []
/// Mqtt and lora
// Search text predicates
if !searchText.isEmpty {
let searchPredicates = ["userId", "numString", "hwModel", "hwDisplayName", "longName", "shortName"].map { property in
return NSPredicate(format: "%K CONTAINS[c] %@", property, searchText)
}
let textSearchPredicate = NSCompoundPredicate(type: .or, subpredicates: searchPredicates)
predicates.append(textSearchPredicate)
}
// Mqtt and lora
if !(viaLora && viaMqtt) {
if viaLora {
let loraPredicate = NSPredicate(format: "userNode.viaMqtt == NO")
predicates.append(loraPredicate)
} else {
let mqttPredicate = NSPredicate(format: "userNode.viaMqtt == YES AND userNode.hopsAway == 0")
let mqttPredicate = NSPredicate(format: "userNode.viaMqtt == YES")
predicates.append(mqttPredicate)
}
} else {
let mqttPredicate = NSPredicate(format: "NOT (userNode.viaMqtt == YES AND userNode.hopsAway > 0)")
predicates.append(mqttPredicate)
}
/// Roles
// Roles
if roleFilter && deviceRoles.count > 0 {
var rolesArray: [NSPredicate] = []
for dr in deviceRoles {
@ -321,33 +296,32 @@ struct UserList: View {
let compoundPredicate = NSCompoundPredicate(type: .or, subpredicates: rolesArray)
predicates.append(compoundPredicate)
}
/// Hops Away
if hopsAway == 0.0 {
// Hops Away
if hopsAway == 0 {
let hopsAwayPredicate = NSPredicate(format: "userNode.hopsAway == %i", Int32(hopsAway))
predicates.append(hopsAwayPredicate)
} else if hopsAway > -1.0 {
let hopsAwayPredicate = NSPredicate(format: "userNode.hopsAway > 0 AND userNode.hopsAway <= %i", Int32(hopsAway))
predicates.append(hopsAwayPredicate)
}
/// Online
// Online
if isOnline {
let isOnlinePredicate = NSPredicate(format: "userNode.lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -120, to: Date())! as NSDate)
predicates.append(isOnlinePredicate)
}
/// Encrypted
// Encrypted
if isPkiEncrypted {
let isPkiEncryptedPredicate = NSPredicate(format: "pkiEncrypted == YES")
predicates.append(isPkiEncryptedPredicate)
}
/// Favorites
// Favorites
if isFavorite {
let isFavoritePredicate = NSPredicate(format: "userNode.favorite == YES")
predicates.append(isFavoritePredicate)
}
/// Distance
// Distance
if distanceFilter {
let pointOfInterest = LocationsHandler.currentLocation
if pointOfInterest.latitude != LocationsHandler.DefaultLocation.latitude && pointOfInterest.longitude != LocationsHandler.DefaultLocation.longitude {
let d: Double = maxDistance * 1.1
let r: Double = 6371009
@ -362,16 +336,29 @@ struct UserList: View {
predicates.append(distancePredicate)
}
}
if predicates.count > 0 || !searchText.isEmpty {
if !searchText.isEmpty {
let filterPredicates = NSCompoundPredicate(type: .and, subpredicates: predicates)
users.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: [textSearchPredicate, filterPredicates])
} else {
users.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates)
}
} else {
users.nsPredicate = nil
}
// Always apply unmessagable and connected node filters
// Show unmessagable nodes only if they have messages, otherwise hide them
let unmessagablePredicate = NSPredicate(format: "unmessagable == NO")
let hasMessagesPredicate = NSPredicate(format: "receivedMessages.@count > 0 OR sentMessages.@count > 0")
let isUnmessagablePredicate = NSCompoundPredicate(type: .or, subpredicates: [unmessagablePredicate, hasMessagesPredicate])
predicates.append(isUnmessagablePredicate)
let isIgnoredPredicate = NSPredicate(format: "userNode.ignored == NO")
predicates.append(isIgnoredPredicate)
let isConnectedNodePredicate = NSPredicate(format: "NOT (numString CONTAINS %@)", String(UserDefaults.preferredPeripheralNum))
predicates.append(isConnectedNodePredicate)
// Combine all predicates
let finalPredicate = predicates.isEmpty ? NSPredicate(value: true) : NSCompoundPredicate(type: .and, subpredicates: predicates)
// Initialize the fetch request with the combined predicate
_fetchRequest = FetchRequest<UserEntity>(
sortDescriptors: [
NSSortDescriptor(key: "lastMessage", ascending: false),
NSSortDescriptor(key: "userNode.favorite", ascending: false),
NSSortDescriptor(key: "pkiEncrypted", ascending: false),
NSSortDescriptor(key: "userNode.lastHeard", ascending: false),
NSSortDescriptor(key: "longName", ascending: true)
],
predicate: finalPredicate,
animation: .spring
)
}
}

View file

@ -14,7 +14,6 @@ struct UserMessageList: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var bleManager: BLEManager
@Environment(\.managedObjectContext) var context
// Keyboard State
@FocusState var messageFieldFocused: Bool
// View State Items
@ -24,16 +23,22 @@ struct UserMessageList: View {
@State private var showScrollToBottomButton = false
@State private var hasReachedBottom = false
@State private var gotFirstUnreadMessage: Bool = false
@State private var messageToHighlight: Int64 = 0
var body: some View {
VStack {
ScrollViewReader { scrollView in
ZStack(alignment: .bottomTrailing) {
ScrollView {
LazyVStack {
ForEach( user.messageList ) { (message: MessageEntity) in
ForEach( Array(user.messageList.enumerated()), id: \.element.id) { index, message in
// Get the previous message, if it exists
let previousMessage = index > 0 ? user.messageList[index - 1] : nil
if message.displayTimestamp(aboveMessage: previousMessage) {
Text(message.timestamp.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundColor(.gray)
}
if user.num != bleManager.connectedPeripheral?.num ?? -1 {
let currentUser: Bool = (Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num ?? -1 ? true : false)
@ -46,7 +51,6 @@ struct UserMessageList: View {
messageToHighlight = messageNum
}
scrollView.scrollTo(messageNum, anchor: .center)
// Reset highlight after delay
Task {
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
@ -119,11 +123,11 @@ struct UserMessageList: View {
Spacer(minLength: 50)
}
}
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(.blue, lineWidth: 2)
.opacity(((messageToHighlight == message.messageId) || (replyMessageId == message.messageId)) ? 1 : 0)
}
// .overlay {
// RoundedRectangle(cornerRadius: 10)
// .stroke(.blue, lineWidth: 2)
// .opacity(((messageToHighlight == message.messageId) || (replyMessageId == message.messageId)) ? 1 : 0)
// }
.padding([.leading, .trailing])
.frame(maxWidth: .infinity)
.id(message.messageId)
@ -163,24 +167,24 @@ struct UserMessageList: View {
}
.scrollDismissesKeyboard(.interactively)
.onFirstAppear {
// Find first unread message
if let firstUnreadMessageId = user.messageList.first(where: { !$0.read })?.messageId {
if user.unreadMessages == 0 {
withAnimation {
scrollView.scrollTo(firstUnreadMessageId, anchor: .top)
showScrollToBottomButton = true
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
hasReachedBottom = true
}
} else {
// If no unread messages, scroll to bottom
withAnimation {
scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom)
hasReachedBottom = true
if let firstUnreadMessageId = user.messageList.first(where: { !$0.read })?.messageId {
withAnimation {
scrollView.scrollTo(firstUnreadMessageId, anchor: .top)
showScrollToBottomButton = true
}
}
}
gotFirstUnreadMessage = true
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in
withAnimation {
scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom)
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
hasReachedBottom = true
showScrollToBottomButton = false
}
@ -188,7 +192,7 @@ struct UserMessageList: View {
.onChange(of: user.messageList) {
if hasReachedBottom {
withAnimation {
scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom)
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
}
} else {
showScrollToBottomButton = true

View file

@ -40,6 +40,7 @@ struct IgnoreNodeButton: View {
Image(systemName: node.ignored ? "minus.circle.fill" : "minus.circle")
.symbolRenderingMode(.multicolor)
}
// Accessibility: Label for VoiceOver
}
}
}

View file

@ -9,25 +9,28 @@ struct TraceRouteButton: View {
private var isPresentingTraceRouteSentAlert: Bool = false
var body: some View {
Button {
RateLimitedButton(key: "traceroute", rateLimit: 30.0) {
isPresentingTraceRouteSentAlert = bleManager.sendTraceRouteRequest(
destNum: node.user?.num ?? 0,
wantResponse: true
)
} label: {
Label {
Text("Trace Route")
} icon: {
Image(systemName: "signpost.right.and.left")
.symbolRenderingMode(.hierarchical)
}
}.alert(
"Trace Route Sent",
isPresented: $isPresentingTraceRouteSentAlert
) {
Button("OK") { }.keyboardShortcut(.defaultAction)
} message: {
Text("This could take a while. The response will appear in the trace route log for the node it was sent to.")
} label: { completion in
if let completion, completion.percentComplete > 0.0 {
Label {
Text("Trace Route (in \(completion.secondsRemaining.formatted(.number.precision(.fractionLength(0))))s)")
.foregroundStyle(.secondary)
} icon: {
Image("progress.ring.dashed", variableValue: completion.percentComplete)
.foregroundStyle(.secondary)
}.disabled(true)
} else {
Label {
Text("Trace Route")
} icon: {
Image(systemName: "signpost.right.and.left")
.symbolRenderingMode(.hierarchical)
}
}
}
}
}

View file

@ -15,6 +15,7 @@ struct MeshMapContent: MapContent {
@AppStorage("meshMapShowNodeHistory") private var showNodeHistory = false
@AppStorage("meshMapShowRouteLines") private var showRouteLines = false
@AppStorage("enableMapConvexHull") private var showConvexHull = false
@AppStorage("enableMapShowFavorites") private var showFavorites = false
@Binding var showTraffic: Bool
@Binding var showPointsOfInterest: Bool
@Binding var selectedMapLayer: MapLayer
@ -39,11 +40,12 @@ struct MeshMapContent: MapContent {
@MapContentBuilder
var positionAnnotations: some MapContent {
ForEach(positions, id: \.id) { position in
/// Node color from node.num
let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0))
let positionName = position.nodePosition?.user?.longName ?? "?"
/// Latest Position Anotations
Annotation(positionName, coordinate: position.coordinate) {
if !showFavorites || (position.nodePosition?.favorite == true) {
/// Node color from node.num
let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0))
let positionName = position.nodePosition?.user?.longName ?? "?"
/// Latest Position Anotations
Annotation(positionName, coordinate: position.coordinate) {
LazyVStack {
ZStack {
let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0))
@ -59,6 +61,13 @@ struct MeshMapContent: MapContent {
.onAppear {
self.scale = 1
}
.onChange(of: showFavorites) {
scale = 0.5 // Reset to initial state
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
scale = 1
}
}
.frame(width: 60, height: 60)
}
if position.nodePosition?.hasDetectionSensorMetrics ?? false {
@ -130,7 +139,7 @@ struct MeshMapContent: MapContent {
}
}
/// Reduced Precision Map Circles
if 10...19 ~= position.precisionBits {
if 12...15 ~= position.precisionBits {
let pp = PositionPrecision(rawValue: Int(position.precisionBits))
let radius: CLLocationDistance = pp?.precisionMeters ?? 0
if radius > 0.0 {
@ -141,6 +150,8 @@ struct MeshMapContent: MapContent {
}
}
}
}
}
@MapContentBuilder

View file

@ -49,7 +49,7 @@ struct NodeMapContent: MapContent {
let pf = PositionFlags(rawValue: Int(position.nodePosition?.metadata?.positionFlags ?? 771))
let headingDegrees = Angle.degrees(Double(position.heading))
/// Reduced Precision Map Circle
if position.latest && 10...19 ~= position.precisionBits {
if position.latest && 12...15 ~= position.precisionBits {
let pp = PositionPrecision(rawValue: Int(position.precisionBits))
let radius: CLLocationDistance = pp?.precisionMeters ?? 0
if radius > 0.0 {

View file

@ -12,9 +12,10 @@ struct MapSettingsForm: View {
@Environment(\.dismiss) private var dismiss
@State private var currentDetent = PresentationDetent.medium
@AppStorage("meshMapShowNodeHistory") private var nodeHistory = false
@AppStorage("meshMapShowRouteLines") private var routeLines = false
@AppStorage("meshMapShowRouteLines") private var enableMapRouteLines = false
@AppStorage("enableMapConvexHull") private var convexHull = false
@AppStorage("enableMapWaypoints") private var waypoints = true
@AppStorage("enableMapWaypoints") private var enableMapWaypoints = true
@AppStorage("enableMapShowFavorites") private var enableMapShowFavorites = false
@Binding var traffic: Bool
@Binding var pointsOfInterest: Bool
@Binding var mapLayer: MapLayer
@ -29,7 +30,7 @@ struct MapSettingsForm: View {
Picker(selection: $mapLayer, label: Text("")) {
ForEach(MapLayer.allCases, id: \.self) { layer in
if layer != MapLayer.offline {
Text(layer.localized)
Text(layer.localized.capitalized)
}
}
}
@ -53,15 +54,25 @@ struct MapSettingsForm: View {
.onChange(of: meshMapDistance) { _, newMeshMapDistance in
UserDefaults.meshMapDistance = newMeshMapDistance
}
Toggle(isOn: $waypoints) {
Label("Show Waypoints ", systemImage: "signpost.right.and.left")
Toggle(isOn: $enableMapWaypoints) {
Label {
Text("Show Waypoints")
} icon: {
Image(systemName: "signpost.right.and.left")
.symbolRenderingMode(.multicolor)
}
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.onTapGesture {
UserDefaults.enableMapWaypoints = !waypoints
.tint(.accentColor)
}
Toggle(isOn: $enableMapShowFavorites) {
Label {
Text("Favorites")
} icon: {
Image(systemName: "star.fill")
.symbolRenderingMode(.multicolor)
}
}
.tint(.accentColor)
Toggle(isOn: $nodeHistory) {
Label("Node History", systemImage: "building.columns.fill")
}
@ -70,15 +81,10 @@ struct MapSettingsForm: View {
self.nodeHistory.toggle()
UserDefaults.enableMapNodeHistoryPins = self.nodeHistory
}
Toggle(isOn: $routeLines) {
Toggle(isOn: $enableMapRouteLines) {
Label("Route Lines", systemImage: "road.lanes")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.onTapGesture {
self.routeLines.toggle()
UserDefaults.enableMapRouteLines = self.routeLines
}
.tint(.accentColor)
Toggle(isOn: $convexHull) {
Label("Convex Hull", systemImage: "button.angledbottom.horizontal.right")
}
@ -96,9 +102,14 @@ struct MapSettingsForm: View {
UserDefaults.enableMapTraffic = self.traffic
}
Toggle(isOn: $pointsOfInterest) {
Label("Points of Interest", systemImage: "mappin.and.ellipse")
Label {
Text("Points of Interest")
} icon: {
Image(systemName: "mappin.and.ellipse")
.symbolRenderingMode(.multicolor)
}
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.tint(.accentColor)
.onTapGesture {
self.pointsOfInterest.toggle()
UserDefaults.enableMapPointsOfInterest = self.pointsOfInterest

View file

@ -156,7 +156,7 @@ struct PositionPopover: View {
if lastLocation.distance(from: CLLocation(latitude: LocationsHandler.DefaultLocation.latitude, longitude: LocationsHandler.DefaultLocation.longitude)) > 0.0 {
let metersAway = position.coordinate.distance(from: CLLocationCoordinate2D(latitude: lastLocation.coordinate.latitude, longitude: lastLocation.coordinate.longitude))
Label {
Text("distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway)))")
Text("Distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway)))")
.foregroundColor(.primary)
.font(idiom == .phone ? .callout : .body)
} icon: {

View file

@ -14,6 +14,7 @@ import SwiftUI
struct WaypointForm: View {
@EnvironmentObject var bleManager: BLEManager
@Environment(\.managedObjectContext) var context
@Environment(\.dismiss) private var dismiss
@State var waypoint: WaypointEntity
let distanceFormatter = MKDistanceFormatter()
@ -30,6 +31,7 @@ struct WaypointForm: View {
@State private var lockedTo: Int64 = 0
@State private var detents: Set<PresentationDetent> = [.medium, .fraction(0.85)]
@State private var selectedDetent: PresentationDetent = .medium
@State private var waypointFailedAlert: Bool = false
var body: some View {
NavigationStack {
@ -47,7 +49,19 @@ struct WaypointForm: View {
.textSelection(.enabled)
.foregroundColor(.secondary)
.font(.caption)
}
Button {
let currentLoc = LocationsHandler.currentLocation
waypoint.coordinate.longitude = currentLoc.longitude
waypoint.coordinate.latitude = currentLoc.latitude
} label: {
HStack {
Text("Use my Location")
Image(systemName: "location")
}
}
.accessibilityLabel("Set to current location")
HStack {
if waypoint.coordinate.latitude != 0 && waypoint.coordinate.longitude != 0 {
DistanceText(meters: distance)
@ -72,6 +86,7 @@ struct WaypointForm: View {
name = String(name.dropLast())
totalBytes = name.utf8.count
}
waypoint.name = name.count > 0 ? name : "Dropped Pin"
}
}
HStack {
@ -167,8 +182,8 @@ struct WaypointForm: View {
if bleManager.sendWaypoint(waypoint: newWaypoint) {
dismiss()
} else {
dismiss()
Logger.mesh.warning("Send waypoint failed")
waypointFailedAlert = true
}
} else {
Logger.mesh.warning("Send waypoint failed, node not connected")
@ -196,11 +211,11 @@ struct WaypointForm: View {
Menu {
Button("For me", action: {
bleManager.context.delete(waypoint)
context.delete(waypoint)
do {
try bleManager.context.save()
try context.save()
} catch {
bleManager.context.rollback()
context.rollback()
}
dismiss() })
Button("For everyone", action: {
@ -225,16 +240,16 @@ struct WaypointForm: View {
newWaypoint.expire = UInt32(1)
if bleManager.sendWaypoint(waypoint: newWaypoint) {
bleManager.context.delete(waypoint)
context.delete(waypoint)
do {
try bleManager.context.save()
try context.save()
} catch {
bleManager.context.rollback()
context.rollback()
}
dismiss()
} else {
dismiss()
Logger.mesh.warning("Send waypoint failed")
waypointFailedAlert = true
}
})
}
@ -256,8 +271,8 @@ struct WaypointForm: View {
Text(waypoint.name ?? "?")
.font(.largeTitle)
Spacer()
if waypoint.locked > 0 {
Image(systemName: "lock.fill" )
if waypoint.locked > 0 && waypoint.locked != UInt32(BLEManager.shared.connectedPeripheral?.num ?? 0) {
Image(systemName: "lock.fill")
.font(.largeTitle)
} else {
Button {
@ -368,21 +383,32 @@ struct WaypointForm: View {
}
}
}
.alert("Waypoint Failed to Send", isPresented: $waypointFailedAlert) {
Button("OK", role: .cancel) {
context.delete(waypoint)
do {
try context.save()
} catch {
context.rollback()
}
dismiss()
}
}
.onDisappear {
if waypoint.id == 0 {
// New, unsent waypoint created by the user: delete it
bleManager.context.delete(waypoint)
context.delete(waypoint)
do {
try bleManager.context.save()
try context.save()
} catch {
bleManager.context.rollback()
context.rollback()
Logger.mesh.error("Failed to save context on waypoint deletion: \(error)")
}
}
}
.onAppear {
if waypoint.id > 0 {
let waypoint = getWaypoint(id: Int64(waypoint.id), context: bleManager.context)
let waypoint = getWaypoint(id: Int64(waypoint.id), context: context)
name = waypoint.name ?? "Dropped Pin"
description = waypoint.longDescription ?? ""
icon = String(UnicodeScalar(Int(waypoint.icon)) ?? "📍")

View file

@ -46,7 +46,8 @@ struct NodeDetail: View {
Section("Hardware") {
NodeInfoItem(node: node)
}
Section("Node") {
.accessibilityElement(children: .combine)
Section("Node") { // Node
HStack(alignment: .center) {
Spacer()
CircleText(
@ -67,6 +68,7 @@ struct NodeDetail: View {
.foregroundColor(getRssiColor(rssi: node.rssi))
.font(.caption)
}
.accessibilityElement(children: .combine)
}
if node.telemetries?.count ?? 0 > 0 {
Spacer()
@ -74,6 +76,7 @@ struct NodeDetail: View {
}
Spacer()
}
.accessibilityElement(children: .combine)
.listRowSeparator(.hidden)
if let user = node.user {
if !user.keyMatch {
@ -86,6 +89,7 @@ struct NodeDetail: View {
.foregroundStyle(.secondary)
.font(.callout)
}
.accessibilityElement(children: .combine)
} icon: {
Image(systemName: "key.slash.fill")
.symbolRenderingMode(.multicolor)
@ -104,6 +108,7 @@ struct NodeDetail: View {
Text(String(node.num))
.textSelection(.enabled)
}
.accessibilityElement(children: .combine)
HStack {
Label {
@ -116,6 +121,32 @@ struct NodeDetail: View {
Text(node.num.toHex())
.textSelection(.enabled)
}
.accessibilityElement(children: .combine)
if node.user?.keyMatch ?? false {
if let publicKey = node.user?.publicKey {
HStack {
Label {
Text("Public Key")
} icon: {
Image(systemName: "lock.fill")
.foregroundColor(.green)
}
Spacer()
Button(action: {
context.perform{
UIPasteboard.general.string = publicKey.base64EncodedString()
}
}) {
HStack {
Image(systemName: "key.horizontal.fill")
Text("Copy")
}
}
}
.accessibilityElement(children: .combine)
}
}
if let metadata = node.metadata {
HStack {
@ -129,6 +160,7 @@ struct NodeDetail: View {
Text(metadata.firmwareVersion ?? "Unknown".localized)
}
.accessibilityElement(children: .combine)
}
if let role = node.user?.role, let deviceRole = DeviceRoles(rawValue: Int(role)) {
@ -142,6 +174,20 @@ struct NodeDetail: View {
Spacer()
Text(deviceRole.name)
}
.accessibilityElement(children: .combine)
}
if node.user?.unmessagable ?? false {
HStack {
Label {
Text("Messaging")
} icon: {
Image(systemName: "iphone.slash")
.symbolRenderingMode(.multicolor)
}
Spacer()
Text("Unmonitored")
}
.accessibilityElement(children: .combine)
}
if let dm = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).lastObject as? TelemetryEntity, let uptimeSeconds = dm.uptimeSeconds {
@ -161,6 +207,7 @@ struct NodeDetail: View {
Text(uptime)
.textSelection(.enabled)
}
.accessibilityElement(children: .combine)
}
if let firstHeard = node.firstHeard, firstHeard.timeIntervalSince1970 > 0 && firstHeard < Calendar.current.date(byAdding: .year, value: 1, to: Date())! {
@ -179,7 +226,9 @@ struct NodeDetail: View {
Text(firstHeard.formatted())
.textSelection(.enabled)
}
}.onTapGesture {
}
.accessibilityElement(children: .combine)
.onTapGesture {
dateFormatRelative.toggle()
}
}
@ -203,7 +252,9 @@ struct NodeDetail: View {
Text(lastHeard.formatted())
.textSelection(.enabled)
}
}.onTapGesture {
}
.accessibilityElement(children: .combine)
.onTapGesture {
dateFormatRelative.toggle()
}
}
@ -216,79 +267,84 @@ struct NodeDetail: View {
if node.hasPositions && UserDefaults.environmentEnableWeatherKit
|| node.hasDataForLatestEnvironmentMetrics(attributes: ["iaq", "temperature", "relativeHumidity", "barometricPressure", "windSpeed", "radiation", "weight", "Distance", "soilTemperature", "soilMoisture"]) {
Section("Environment") {
if !node.hasEnvironmentMetrics {
LocalWeatherConditions(location: node.latestPosition?.nodeLocation)
} else {
VStack {
if node.latestEnvironmentMetrics?.iaq ?? -1 > 0 {
IndoorAirQuality(iaq: Int(node.latestEnvironmentMetrics?.iaq ?? 0), displayMode: .gradient)
.padding(.vertical)
}
LazyVGrid(columns: gridItemLayout) {
if let temperature = node.latestEnvironmentMetrics?.temperature?.shortFormattedTemperature() {
WeatherConditionsCompactWidget(temperature: String(temperature), symbolName: "cloud.sun", description: "TEMP")
// Group weather/environment data for better VoiceOver experience
VStack {
if !node.hasEnvironmentMetrics {
LocalWeatherConditions(location: node.latestPosition?.nodeLocation)
} else {
VStack {
if node.latestEnvironmentMetrics?.iaq ?? -1 > 0 {
IndoorAirQuality(iaq: Int(node.latestEnvironmentMetrics?.iaq ?? 0), displayMode: .gradient)
.padding(.vertical)
}
if let humidity = node.latestEnvironmentMetrics?.relativeHumidity {
if let temperature = node.latestEnvironmentMetrics?.temperature {
let dewPoint = calculateDewPoint(temp: temperature, relativeHumidity: humidity)
.formatted(.number.precision(.fractionLength(0))) + "°"
HumidityCompactWidget(humidity: Int(humidity), dewPoint: dewPoint)
} else {
HumidityCompactWidget(humidity: Int(humidity), dewPoint: nil)
LazyVGrid(columns: gridItemLayout) {
if let temperature = node.latestEnvironmentMetrics?.temperature?.shortFormattedTemperature() {
WeatherConditionsCompactWidget(temperature: String(temperature), symbolName: "cloud.sun", description: "TEMP")
}
if let humidity = node.latestEnvironmentMetrics?.relativeHumidity {
if let temperature = node.latestEnvironmentMetrics?.temperature {
let dewPoint = calculateDewPoint(temp: temperature, relativeHumidity: humidity)
.formatted(.number.precision(.fractionLength(0))) + "°"
HumidityCompactWidget(humidity: Int(humidity), dewPoint: dewPoint)
} else {
HumidityCompactWidget(humidity: Int(humidity), dewPoint: nil)
}
}
if let pressure = node.latestEnvironmentMetrics?.barometricPressure {
PressureCompactWidget(pressure: pressure.formatted(.number.precision(.fractionLength(2))), unit: "hPA", low: pressure <= 1009.144)
}
if let windSpeed = node.latestEnvironmentMetrics?.windSpeed {
let windSpeedMeasurement = Measurement(value: Double(windSpeed), unit: UnitSpeed.metersPerSecond)
let windGust = node.latestEnvironmentMetrics?.windGust.map { Measurement(value: Double($0), unit: UnitSpeed.metersPerSecond) }
let direction = cardinalValue(from: Double(node.latestEnvironmentMetrics?.windDirection ?? 0))
WindCompactWidget(speed: windSpeedMeasurement.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))),
gust: node.latestEnvironmentMetrics?.windGust ?? 0.0 > 0.0 ? windGust?.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))) : "", direction: direction)
}
if let rainfall1h = node.latestEnvironmentMetrics?.rainfall1H {
let locale = NSLocale.current as NSLocale
let usesMetricSystem = locale.usesMetricSystem // Returns true for metric (mm), false for imperial (inches)
let unit = usesMetricSystem ? UnitLength.millimeters : UnitLength.inches
let unitLabel = usesMetricSystem ? "mm" : "in"
let measurement = Measurement(value: Double(rainfall1h), unit: UnitLength.millimeters)
let decimals = usesMetricSystem ? 0 : 1
let formattedRain = measurement.converted(to: unit).value.formatted(.number.precision(.fractionLength(decimals)))
RainfallCompactWidget(timespan: .rainfall1H, rainfall: formattedRain, unit: unitLabel)
}
if let rainfall24h = node.latestEnvironmentMetrics?.rainfall24H {
let locale = NSLocale.current as NSLocale
let usesMetricSystem = locale.usesMetricSystem // Returns true for metric (mm), false for imperial (inches)
let unit = usesMetricSystem ? UnitLength.millimeters : UnitLength.inches
let unitLabel = usesMetricSystem ? "mm" : "in"
let measurement = Measurement(value: Double(rainfall24h), unit: UnitLength.millimeters)
let decimals = usesMetricSystem ? 0 : 1
let formattedRain = measurement.converted(to: unit).value.formatted(.number.precision(.fractionLength(decimals)))
RainfallCompactWidget(timespan: .rainfall24H, rainfall: formattedRain, unit: unitLabel)
}
if let radiation = node.latestEnvironmentMetrics?.radiation {
RadiationCompactWidget(radiation: radiation.formatted(.number.precision(.fractionLength(1))), unit: "µR/hr")
}
if let weight = node.latestEnvironmentMetrics?.weight {
WeightCompactWidget(weight: weight.formatted(.number.precision(.fractionLength(1))), unit: "kg")
}
if let distance = node.latestEnvironmentMetrics?.distance {
DistanceCompactWidget(distance: distance.formatted(.number.precision(.fractionLength(0))), unit: "mm")
}
if let soilTemperature = node.latestEnvironmentMetrics?.soilTemperature {
let locale = NSLocale.current as NSLocale
let localeUnit = locale.object(forKey: NSLocale.Key(rawValue: "kCFLocaleTemperatureUnitKey"))
let unit = localeUnit as? String ?? "Celsius" == "Fahrenheit" ? "°F" : "°C"
SoilTemperatureCompactWidget(temperature: soilTemperature.localeTemperature().formatted(.number.precision(.fractionLength(0))), unit: unit)
}
if let soilMoisture = node.latestEnvironmentMetrics?.soilMoisture {
SoilMoistureCompactWidget(moisture: soilMoisture.formatted(.number.precision(.fractionLength(0))), unit: "%")
}
}
if let pressure = node.latestEnvironmentMetrics?.barometricPressure {
PressureCompactWidget(pressure: pressure.formatted(.number.precision(.fractionLength(2))), unit: "hPA", low: pressure <= 1009.144)
}
if let windSpeed = node.latestEnvironmentMetrics?.windSpeed {
let windSpeedMeasurement = Measurement(value: Double(windSpeed), unit: UnitSpeed.metersPerSecond)
let windGust = node.latestEnvironmentMetrics?.windGust.map { Measurement(value: Double($0), unit: UnitSpeed.metersPerSecond) }
let direction = cardinalValue(from: Double(node.latestEnvironmentMetrics?.windDirection ?? 0))
WindCompactWidget(speed: windSpeedMeasurement.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))),
gust: node.latestEnvironmentMetrics?.windGust ?? 0.0 > 0.0 ? windGust?.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))) : "", direction: direction)
}
if let rainfall1h = node.latestEnvironmentMetrics?.rainfall1H {
let locale = NSLocale.current as NSLocale
let usesMetricSystem = locale.usesMetricSystem // Returns true for metric (mm), false for imperial (inches)
let unit = usesMetricSystem ? UnitLength.millimeters : UnitLength.inches
let unitLabel = usesMetricSystem ? "mm" : "in"
let measurement = Measurement(value: Double(rainfall1h), unit: UnitLength.millimeters)
let decimals = usesMetricSystem ? 0 : 1
let formattedRain = measurement.converted(to: unit).value.formatted(.number.precision(.fractionLength(decimals)))
RainfallCompactWidget(timespan: .rainfall1H, rainfall: formattedRain, unit: unitLabel)
}
if let rainfall24h = node.latestEnvironmentMetrics?.rainfall24H {
let locale = NSLocale.current as NSLocale
let usesMetricSystem = locale.usesMetricSystem // Returns true for metric (mm), false for imperial (inches)
let unit = usesMetricSystem ? UnitLength.millimeters : UnitLength.inches
let unitLabel = usesMetricSystem ? "mm" : "in"
let measurement = Measurement(value: Double(rainfall24h), unit: UnitLength.millimeters)
let decimals = usesMetricSystem ? 0 : 1
let formattedRain = measurement.converted(to: unit).value.formatted(.number.precision(.fractionLength(decimals)))
RainfallCompactWidget(timespan: .rainfall24H, rainfall: formattedRain, unit: unitLabel)
}
if let radiation = node.latestEnvironmentMetrics?.radiation {
RadiationCompactWidget(radiation: radiation.formatted(.number.precision(.fractionLength(1))), unit: "µR/hr")
}
if let weight = node.latestEnvironmentMetrics?.weight {
WeightCompactWidget(weight: weight.formatted(.number.precision(.fractionLength(1))), unit: "kg")
}
if let distance = node.latestEnvironmentMetrics?.distance {
DistanceCompactWidget(distance: distance.formatted(.number.precision(.fractionLength(0))), unit: "mm")
}
if let soilTemperature = node.latestEnvironmentMetrics?.soilTemperature {
let locale = NSLocale.current as NSLocale
let localeUnit = locale.object(forKey: NSLocale.Key(rawValue: "kCFLocaleTemperatureUnitKey"))
let unit = localeUnit as? String ?? "Celsius" == "Fahrenheit" ? "°F" : "°C"
SoilTemperatureCompactWidget(temperature: soilTemperature.localeTemperature().formatted(.number.precision(.fractionLength(0))), unit: unit)
}
if let soilMoisture = node.latestEnvironmentMetrics?.soilMoisture {
SoilMoistureCompactWidget(moisture: soilMoisture.formatted(.number.precision(.fractionLength(0))), unit: "%")
}
.padding(node.latestEnvironmentMetrics?.iaq ?? -1 > 0 ? .bottom : .vertical)
}
.padding(node.latestEnvironmentMetrics?.iaq ?? -1 > 0 ? .bottom : .vertical)
}
}
// Apply accessibility properties to the environment section
.accessibilityElement(children: .combine)
}
}
if node.hasPowerMetrics && node.latestPowerMetrics != nil {
@ -298,6 +354,7 @@ struct NodeDetail: View {
PowerMetrics(metric: metric)
}
}
.accessibilityElement(children: .combine)
}
}
Section("Logs") {
@ -454,12 +511,11 @@ struct NodeDetail: View {
let connectedNode,
self.bleManager.connectedPeripheral != nil {
Section("Administration") {
if connectedNode.myInfo?.hasAdmin ?? false {
if UserDefaults.enableAdministration {
Button {
let adminMessageId = bleManager.requestDeviceMetadata(
fromUser: connectedNode.user!,
toUser: node.user!,
adminIndex: connectedNode.myInfo!.adminIndex,
context: context
)
if adminMessageId > 0 {
@ -486,8 +542,7 @@ struct NodeDetail: View {
Button("Shutdown Node?", role: .destructive) {
if !bleManager.sendShutdown(
fromUser: connectedNode.user!,
toUser: node.user!,
adminIndex: connectedNode.myInfo!.adminIndex
toUser: node.user!
) {
Logger.mesh.warning("Shutdown Failed")
}
@ -509,8 +564,7 @@ struct NodeDetail: View {
Button("Reboot node?", role: .destructive) {
if !bleManager.sendReboot(
fromUser: connectedNode.user!,
toUser: node.user!,
adminIndex: connectedNode.myInfo!.adminIndex
toUser: node.user!
) {
Logger.mesh.warning("Reboot Failed")
}

View file

@ -31,6 +31,7 @@ struct NodeInfoItem: View {
.foregroundStyle(.gray)
.font(.callout)
}
.accessibilityElement(children: .combine)
Spacer()
}
VStack(alignment: .center) {
@ -49,9 +50,11 @@ struct NodeInfoItem: View {
.cornerRadius(5)
}
}
.accessibilityElement(children: .combine)
}
Spacer()
}
.accessibilityElement(children: .combine)
.onAppear {
Api().loadDeviceHardwareData { (hw) in
for device in hw {
@ -76,9 +79,10 @@ struct NodeInfoItem: View {
if user.hwModel != "UNSET" {
Text(String(node.user?.hwDisplayName ?? (node.user?.hwModel ?? "Unset".localized)))
} else {
Text(String("incomplete".localized))
Text(String("Incomplete".localized))
}
}
.accessibilityElement(children: .combine)
}
}
}

View file

@ -91,20 +91,21 @@ struct NodeListFilter: View {
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(.visible)
Toggle(isOn: $isIgnored) {
Label {
Text("Ignored")
} icon: {
Image(systemName: "minus.circle.fill")
.symbolRenderingMode(.multicolor)
}
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(.visible)
if filterTitle == "Node Filters" {
Toggle(isOn: $isIgnored) {
Label {
Text("Ignored")
} icon: {
Image(systemName: "minus.circle.fill")
.symbolRenderingMode(.multicolor)
}
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(.visible)
Toggle(isOn: $isEnvironment) {
Label {
Text("Environment")

View file

@ -7,9 +7,96 @@
import SwiftUI
import CoreLocation
import Foundation
struct NodeListItem: View {
// Accessibility: Synthesized description for VoiceOver
private var accessibilityDescription: String {
var desc = ""
if let shortName = node.user?.shortName {
// Format the shortName using the String extension method
desc = shortName.formatNodeNameForVoiceOver()
} else if let longName = node.user?.longName {
desc = longName
} else {
desc = "Unknown".localized + " " + "Node".localized
}
if connected {
desc += ", currently connected"
}
if node.favorite {
desc += ", favorite"
}
if node.lastHeard != nil {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
let relative = formatter.localizedString(for: node.lastHeard!, relativeTo: Date())
desc += ", last heard " + relative
}
if node.isOnline {
desc += ", online"
} else {
desc += ", offline"
}
let role = DeviceRoles(rawValue: Int(node.user?.role ?? 0))
if let roleName = role?.name {
desc += ", role: \(roleName)"
}
if node.hopsAway > 0 {
desc += ", \(node.hopsAway) hops away"
}
if let battery = node.latestDeviceMetrics?.batteryLevel {
// Check for plugged in and charging states, same logic as in BatteryCompact and BatteryGauge
if battery > 100 {
desc += ", " + "Plugged in".localized
} else if battery == 100 {
desc += ", " + "Charging".localized
} else {
desc += ", battery \(battery)%"
}
}
// Add distance and heading/bearing if available, but only for non-connected nodes
if !connected, let (lastPosition, myCoord) = locationData {
let nodeCoord = CLLocation(latitude: lastPosition.nodeCoordinate!.latitude, longitude: lastPosition.nodeCoordinate!.longitude)
let metersAway = nodeCoord.distance(from: myCoord)
// Distance information
let distanceFormatter = LengthFormatter()
distanceFormatter.unitStyle = .medium
let formattedDistance = distanceFormatter.string(fromMeters: metersAway)
// For VoiceOver, prepend 'Distance' (localized)
desc += ", " + String(format: "%@: %@", "Distance".localized, formattedDistance)
// Add bearing/heading information for VoiceOver
let trueBearing = getBearingBetweenTwoPoints(point1: myCoord, point2: nodeCoord)
let heading = Measurement(value: trueBearing, unit: UnitAngle.degrees)
let formattedHeading = heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0))))
// Using a direct format without requiring a new localization key
desc += ", " + "Heading".localized + " " + formattedHeading
}
// Add signal strength if available
if node.snr != 0 && !node.viaMqtt {
let signalStrength: BLESignalStrength
if node.snr < -10 {
signalStrength = .weak
} else if node.snr < 5 {
signalStrength = .normal
} else {
signalStrength = .strong
}
let signalString: String
switch signalStrength {
case .weak:
signalString = "Signal strength weak".localized
case .normal:
signalString = "Signal strength normal".localized
case .strong:
signalString = "Signal strength strong".localized
}
desc += ", " + signalString
}
return desc
}
@ObservedObject var node: NodeInfoEntity
var connected: Bool
var connectedNode: Int64
@ -85,6 +172,11 @@ struct NodeListItem: View {
let role = DeviceRoles(rawValue: Int(node.user?.role ?? 0))
IconAndText(systemName: role?.systemName ?? "figure",
text: "Role: \(role?.name ?? "Unknown".localized)")
if node.user?.unmessagable ?? false {
IconAndText(systemName: "iphone.slash",
renderingMode: .multicolor,
text: "Unmonitored")
}
if node.isStoreForwardRouter {
IconAndText(systemName: "envelope.arrow.triangle.branch",
renderingMode: .multicolor,
@ -167,7 +259,10 @@ struct NodeListItem: View {
}
.padding(.top, 4)
.padding(.bottom, 4)
}
// Accessibility: Make the whole row a single element for VoiceOver
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
}
}
struct DefaultIcon: View {

View file

@ -0,0 +1,92 @@
// ShareContactQRDialog.swift
// Meshtastic
//
// Created by GitHub Copilot on 5/13/25.
import SwiftUI
import CoreImage.CIFilterBuiltins
#if canImport(UIKit)
import UIKit
#endif
import CoreData
import MeshtasticProtobufs
import OSLog
struct ShareContactQRDialog: View {
let node: NodeInfo
@Environment(\.dismiss) private var dismiss
var qrString: String {
var contact = SharedContact()
contact.nodeNum = node.num
contact.user = node.user
do {
let contactString = try contact.serializedData().base64EncodedString()
return ("https://meshtastic.org/v/#" + contactString.base64ToBase64url())
} catch {
Logger.services.error("Error serializing contact: \(error)")
return ""
}
}
var qrImage: UIImage {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.setValue(Data(qrString.utf8), forKey: "inputMessage")
let transform = CGAffineTransform(scaleX: 10, y: 10)
if let outputImage = filter.outputImage?.transformed(by: transform),
let cgimg = context.createCGImage(outputImage, from: outputImage.extent) {
return UIImage(cgImage: cgimg)
}
return UIImage(systemName: "xmark.circle") ?? UIImage()
}
var body: some View {
VStack(spacing: 20) {
Text("Share Contact QR")
.font(.title2)
.padding(.top)
Text(node.user.longName)
.font(.headline)
Image(uiImage: qrImage)
.interpolation(.none)
.resizable()
.scaledToFit()
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(radius: 4)
Text("Scan this QR code to add \(node.user.longName) to another device.")
.font(.subheadline)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
ShareLink("Share QR Code & Link",
item: Image(uiImage: qrImage),
subject: Text("Add Meshtastic Node \(node.user.shortName) as a contact"),
message: Text(qrString),
preview: SharePreview("Add Meshtastic Node \(node.user.shortName) as a contact",
image: Image(uiImage: qrImage))
)
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.padding(.bottom)
}
.padding()
.frame(maxWidth: 350)
}
}
#if DEBUG
struct ShareContactQRDialog_Previews: PreviewProvider {
static var previews: some View {
var node = NodeInfo()
node.num = 123456
var userProto = User()
userProto.id = "!1234"
userProto.longName = "Bud"
userProto.shortName = "Bud"
userProto.hwModel = HardwareModel.tbeam
userProto.role = Config.DeviceConfig.Role.client
userProto.publicKey = Data()
node.user = userProto
return ShareContactQRDialog(node: node)
}
}
#endif

View file

@ -28,6 +28,8 @@ struct NodeList: View {
@State private var isFavorite = false
@State private var isIgnored = false
@State private var isEnvironment = false
// Force refresh ID to make SwiftUI rebuild the view hierarchy
@State private var forceRefreshID = UUID()
@State private var distanceFilter = false
@State private var maxDistance: Double = 800000
@State private var hopsAway: Double = -1.0
@ -38,6 +40,8 @@ struct NodeList: View {
@State private var isPresentingPositionFailedAlert = false
@State private var isPresentingDeleteNodeAlert = false
@State private var deleteNodeId: Int64 = 0
@State private var isPresentingShareContactQR = false
@State private var shareContactNode: NodeInfoEntity?
var boolFilters: [Bool] {[
isFavorite,
@ -76,13 +80,21 @@ struct NodeList: View {
/// Allow users to mute notifications for a node even if they are not connected
if let user = node.user {
NodeAlertsButton(context: context, node: node, user: user)
if !user.unmessagable {
Button(action: {
shareContactNode = node
isPresentingShareContactQR = true
}) {
Label("Share Contact QR", systemImage: "qrcode")
}
}
}
if let connectedNode {
/// Favoriting a node requires being connected
FavoriteNodeButton(bleManager: bleManager, context: context, node: node)
/// Don't show message, trace route, position exchange or delete context menu items for the connected node
if connectedNode.num != node.num {
if !node.viaMqtt || node.viaMqtt && node.hopsAway == 0 {
if !(node.user?.unmessagable ?? true) {
Button(action: {
if let url = URL(string: "meshtastic:///messages?userNum=\(node.num)") {
UIApplication.shared.open(url)
@ -91,21 +103,10 @@ struct NodeList: View {
Label("Message", systemImage: "message")
}
}
Button {
let traceRouteSent = bleManager.sendTraceRouteRequest(
destNum: node.num,
wantResponse: true
)
if traceRouteSent {
isPresentingTraceRouteSentAlert = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
isPresentingTraceRouteSentAlert = false
}
}
} label: {
Label("Trace Route", systemImage: "signpost.right.and.left")
}
TraceRouteButton(
bleManager: bleManager,
node: node
)
Button {
let positionSent = bleManager.sendPosition(
channel: node.channel,
@ -142,6 +143,7 @@ struct NodeList: View {
}
var body: some View {
// Use forceRefreshID to completely rebuild the view when notifications update the selected node
NavigationSplitView(columnVisibility: $columnVisibility) {
List(nodes, id: \.self, selection: $selectedNode) { node in
NodeListItem(
@ -231,6 +233,13 @@ struct NodeList: View {
}
}
}
}
.sheet(isPresented: $isPresentingShareContactQR) {
if let node = shareContactNode {
ShareContactQRDialog(node: node.toProto())
} else {
EmptyView()
}
}
.navigationSplitViewColumnWidth(min: 100, ideal: 250, max: 500)
.navigationBarItems(
@ -243,6 +252,8 @@ struct NodeList: View {
phoneOnly: true
)
}
// Make sure the ZStack passes through accessibility to the ConnectedDevice component
.accessibilityElement(children: .contain)
)
} content: {
if let node = selectedNode {
@ -261,6 +272,7 @@ struct NodeList: View {
} label: {
Image(systemName: "rectangle")
}
.accessibilityLabel("Hide sidebar")
}
ConnectedDevice(
bluetoothOn: bleManager.isSwitchedOn,
@ -269,6 +281,8 @@ struct NodeList: View {
phoneOnly: true
)
}
// Make sure the ZStack passes through accessibility to the ConnectedDevice component
.accessibilityElement(children: .contain)
)
}
} else {
@ -326,16 +340,40 @@ struct NodeList: View {
}
.onChange(of: router.navigationState) {
if let selected = router.navigationState.nodeListSelectedNodeNum {
self.selectedNode = getNodeInfo(id: selected, context: context)
// Force a complete view rebuild by generating a new UUID
Logger.services.info("Forcing view rebuild with new ID: \(self.forceRefreshID)")
// First clear selection
self.forceRefreshID = UUID()
self.selectedNode = nil
// Then after a short delay, set the new selection. Makes it obvious to use page is refreshing too.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
// Generate another UUID to ensure view gets rebuilt
self.forceRefreshID = UUID()
self.selectedNode = getNodeInfo(id: selected, context: context)
Logger.services.info("Complete view refresh with node: \(selected, privacy: .public)")
}
} else {
self.selectedNode = nil
}
}
.onAppear {
// Set up notification observer for forced refreshes from notifications
NotificationCenter.default.addObserver(forName: NSNotification.Name("ForceNavigationRefresh"), object: nil, queue: .main) { notification in
if let nodeNum = notification.userInfo?["nodeNum"] as? Int64 {
// Force complete refresh of view
self.forceRefreshID = UUID()
self.selectedNode = getNodeInfo(id: nodeNum, context: self.context)
Logger.services.info("NodeList directly updated from notification for node: \(nodeNum, privacy: .public)")
}
}
Task {
await searchNodeList()
}
}
.onDisappear {
// Remove observer when view disappears
NotificationCenter.default.removeObserver(self, name: NSNotification.Name("ForceNavigationRefresh"), object: nil)
}
}
private func searchNodeList() async {

View file

@ -38,7 +38,9 @@ struct AboutMeshtastic: View {
}
}
}
Link("Help with App Development", destination: URL(string: "https://github.com/meshtastic/Meshtastic-Apple")!)
Link("Sponsor App Development", destination: URL(string: "https://github.com/sponsors/garthvh")!)
.font(.title2)
Link("GitHub Repository", destination: URL(string: "https://github.com/meshtastic/Meshtastic-Apple")!)
.font(.title2)
Button("Review the app") {
if let scene = UIApplication.shared.connectedScenes

View file

@ -6,13 +6,17 @@ import MapKit
import OSLog
struct AppSettings: View {
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
@Environment(\.managedObjectContext) var context
@EnvironmentObject var bleManager: BLEManager
@State var totalDownloadedTileSize = ""
@State private var isPresentingCoreDataResetConfirm = false
@State private var isPresentingDeleteMapTilesConfirm = false
@State private var purgeStaleNodes: Bool = false
@AppStorage("purgeStaleNodeDays") private var purgeStaleNodeDays: Double = 0
@AppStorage("environmentEnableWeatherKit") private var environmentEnableWeatherKit: Bool = true
@AppStorage("enableAdministration") private var enableAdministration: Bool = false
@AppStorage("usageDataAndCrashReporting") private var usageDataAndCrashReporting: Bool = true
var body: some View {
VStack {
Form {
@ -30,6 +34,13 @@ struct AppSettings: View {
Text("PKI based node administration, requires firmware version 2.5+")
.foregroundStyle(.secondary)
.font(.caption)
Toggle(isOn: $usageDataAndCrashReporting) {
Label("Usage and Crash Data", systemImage: "pencil.and.list.clipboard")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text("Provide anonymous usage statistics and crash reports.")
.foregroundStyle(.secondary)
.font(.caption)
}
Section(header: Text("environment")) {
VStack(alignment: .leading) {
@ -40,6 +51,39 @@ struct AppSettings: View {
}
}
Section(header: Text("App Data")) {
Toggle(isOn: $purgeStaleNodes ) {
Label {
Text("Clear Stale Nodes")
} icon: {
Image(systemName: "list.bullet.circle")
}
}
.onFirstAppear {
purgeStaleNodes = purgeStaleNodeDays > 0
Logger.services.info(" Purge Stale Nodes toggle initialized to \(purgeStaleNodes)")
}
.onChange(of: purgeStaleNodes) { _, newValue in
purgeStaleNodeDays = purgeStaleNodeDays > 0 ? purgeStaleNodeDays : 7
purgeStaleNodeDays = newValue ? purgeStaleNodeDays : 0
Logger.services.info(" Purge Stale Nodes changed to \(purgeStaleNodeDays)")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(purgeStaleNodes ? .hidden : .visible)
if purgeStaleNodes {
VStack(alignment: .leading) {
Text(String(localized: "After \(Int(purgeStaleNodeDays)) Days"))
Slider(value: $purgeStaleNodeDays, in: 1...180, step: 1) {
} minimumValueLabel: {
Text("1")
} maximumValueLabel: {
Text("180")
}
}
Text("Favorited and ignored nodes are always retained. Nodes without PKC keys are cleared from the app database on the schedule set by the user, nodes with PKC keys are cleared only if the interval is set to 7 days or longer. This feature only purges nodes from the app that are not stored in the device node database.")
.foregroundStyle(.secondary)
.font(idiom == .phone ? .caption : .callout)
}
Button {
isPresentingCoreDataResetConfirm = true
} label: {

View file

@ -47,6 +47,7 @@ struct Channels: View {
/// Minimum Version for granular position configuration
@State var minimumVersion = "2.2.24"
@State private var showingHelp = false
@FetchRequest(
sortDescriptors: [NSSortDescriptor(key: "favorite", ascending: false),
@ -124,6 +125,7 @@ struct Channels: View {
.brightness(0.1)
VStack {
HStack {
ChannelLock(channel: channel)
if channel.name?.isEmpty ?? false {
if channel.role == 1 {
Text(String("PrimaryChannel").camelCaseToWords()).font(.headline)
@ -239,6 +241,7 @@ struct Channels: View {
#endif
}
}
if node?.myInfo?.channels?.array.count ?? 0 < 8 && node != nil {
Button {
@ -279,6 +282,29 @@ struct Channels: View {
.padding()
}
}
.sheet(isPresented: $showingHelp) {
ChannelsHelp()
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.safeAreaInset(edge: .bottom, alignment: .leading) {
HStack {
Button(action: {
withAnimation {
showingHelp = !showingHelp
}
}) {
Image(systemName: !showingHelp ? "questionmark.circle" : "questionmark.circle.fill")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
}
.controlSize(.regular)
.padding(5)
}
.padding(.bottom, 5)
.navigationTitle("Channels")
.navigationBarItems(trailing:
ZStack {

View file

@ -148,7 +148,7 @@ struct ChannelForm: View {
.listRowSeparator(.visible)
.onChange(of: preciseLocation) { _, pl in
if pl == false {
positionPrecision = 14
positionPrecision = 15
}
}
}
@ -157,11 +157,11 @@ struct ChannelForm: View {
VStack(alignment: .leading) {
Label("Approximate Location", systemImage: "location.slash.circle.fill")
Slider(value: $positionPrecision, in: 11...14, step: 1) {
Slider(value: $positionPrecision, in: 12...15, step: 1) {
} minimumValueLabel: {
Image(systemName: "minus")
} maximumValueLabel: {
Image(systemName: "plus")
} maximumValueLabel: {
Image(systemName: "minus")
}
Text(PositionPrecision(rawValue: Int(positionPrecision))?.description ?? "")
.foregroundColor(.gray)
@ -228,7 +228,7 @@ struct ChannelForm: View {
.onChange(of: positionsEnabled) { _, pe in
if pe {
if positionPrecision == 0 {
positionPrecision = 14
positionPrecision = 15
}
} else {
positionPrecision = 0

View file

@ -80,7 +80,7 @@ struct BluetoothConfig: View {
bc.enabled = enabled
bc.mode = BluetoothModes(rawValue: mode)?.protoEnumValue() ?? Config.BluetoothConfig.PairingMode.randomPin
bc.fixedPin = UInt32(fixedPin) ?? 123456
let adminMessageId = bleManager.saveBluetoothConfig(config: bc, fromUser: connectedNode.user!, toUser: node!.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveBluetoothConfig(config: bc, fromUser: connectedNode.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -111,12 +111,11 @@ struct BluetoothConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.bluetoothConfig == nil {
Logger.mesh.info("⚙️ Empty or expired bluetooth config requesting via PKI admin")
_ = bleManager.requestBluetoothConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestBluetoothConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty bluetooth config")
_ = bleManager.requestBluetoothConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

View file

@ -11,7 +11,7 @@ struct ConfigHeader<T>: View {
var body: some View {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")
Text("There has been no response to a request for device metadata via PKC admin for this node.")
.font(.callout)
.foregroundColor(.orange)
@ -19,7 +19,7 @@ struct ConfigHeader<T>: View {
// Let users know what is going on if they are using remote admin and don't have the config yet
let expiration = node?.sessionExpiration ?? Date()
if node?[keyPath: config] == nil || expiration < node?.sessionExpiration ?? Date() {
Text("\(title) config data was requested over the admin channel but no response has been returned from the remote node.")
Text("\(title) config data was requested via PKC admin but no response has been returned from the remote node.")
.font(.callout)
.foregroundColor(.orange)
} else {

View file

@ -204,11 +204,11 @@ struct DeviceConfig: View {
.controlSize(.regular)
.padding(.trailing)
.confirmationDialog(
"All device and app data will be deleted.",
"Factory reset will delete device and app data.",
isPresented: $isPresentingFactoryResetConfirm,
titleVisibility: .visible
) {
Button("Factory reset your device and app? ", role: .destructive) {
Button("Delete all config? ", role: .destructive) {
if bleManager.sendFactoryReset(fromUser: node!.user!, toUser: node!.user!) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
bleManager.disconnectPeripheral()
@ -218,6 +218,16 @@ struct DeviceConfig: View {
Logger.mesh.error("Factory Reset Failed")
}
}
Button("Delete all config, keys and BLE bonds? ", role: .destructive) {
if bleManager.sendFactoryReset(fromUser: node!.user!, toUser: node!.user!, resetDevice: true) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
bleManager.disconnectPeripheral()
clearCoreDataDatabase(context: context, includeRoutes: false)
}
} else {
Logger.mesh.error("Factory Reset Failed")
}
}
}
}
}
@ -235,7 +245,7 @@ struct DeviceConfig: View {
dc.disableTripleClick = !tripleClickAsAdHocPing
dc.tzdef = tzdef
dc.ledHeartbeatDisabled = !ledHeartbeatEnabled
let adminMessageId = bleManager.saveDeviceConfig(config: dc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveDeviceConfig(config: dc, fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -268,13 +278,12 @@ struct DeviceConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.deviceConfig == nil {
Logger.mesh.info("⚙️ Empty or expired device config requesting via PKI admin")
_ = bleManager.requestDeviceConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestDeviceConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
if node.deviceConfig == nil {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty device config")
_ = bleManager.requestDeviceConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

View file

@ -27,6 +27,7 @@ struct DisplayConfig: View {
@State var oledType = 0
@State var displayMode = 0
@State var units = 0
@State var use12HourClock = false
var body: some View {
Form {
@ -74,6 +75,11 @@ struct DisplayConfig: View {
.font(.callout)
}
.pickerStyle(DefaultPickerStyle())
Toggle(isOn: $use12HourClock) {
Label("12 Hour Clock", systemImage: "clock")
Text("Sets the screen clock format to 12-hour.")
}
.tint(Color.accentColor)
}
Section(header: Text("Timing & Format")) {
VStack(alignment: .leading) {
@ -141,8 +147,9 @@ struct DisplayConfig: View {
dc.oled = OledTypes(rawValue: oledType)!.protoEnumValue()
dc.displaymode = DisplayModes(rawValue: displayMode)!.protoEnumValue()
dc.units = Units(rawValue: units)!.protoEnumValue()
dc.use12HClock = use12HourClock
let adminMessageId = bleManager.saveDisplayConfig(config: dc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveDisplayConfig(config: dc, fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
@ -174,12 +181,11 @@ struct DisplayConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.displayConfig == nil {
Logger.mesh.info("⚙️ Empty or expired display config requesting via PKI admin")
_ = bleManager.requestDisplayConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestDisplayConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty display config")
_ = bleManager.requestDisplayConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}
@ -212,6 +218,9 @@ struct DisplayConfig: View {
.onChange(of: units) { oldUnits, newUnits in
if oldUnits != newUnits && newUnits != node?.displayConfig?.units ?? -1 { hasChanges = true }
}
.onChange(of: use12HourClock) { oldUse12HourClock, newUse12HourClock in
if oldUse12HourClock != newUse12HourClock && newUse12HourClock != node?.displayConfig?.use12HClock { hasChanges = true }
}
}
func setDisplayValues() {
self.gpsFormat = Int(node?.displayConfig?.gpsFormat ?? 0)
@ -223,6 +232,7 @@ struct DisplayConfig: View {
self.oledType = Int(node?.displayConfig?.oledType ?? 0)
self.displayMode = Int(node?.displayConfig?.displayMode ?? 0)
self.units = Int(node?.displayConfig?.units ?? 0)
self.hasChanges = false
self.use12HourClock = node?.displayConfig?.use12HClock ?? false
self.hasChanges = node?.displayConfig?.use12HClock ?? false
}
}

View file

@ -218,7 +218,7 @@ struct LoRaConfig: View {
if connectedNode?.num ?? -1 == node?.user?.num ?? 0 {
UserDefaults.modemPreset = modemPreset
}
let adminMessageId = bleManager.saveLoRaConfig(config: lc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveLoRaConfig(config: lc, fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -249,12 +249,13 @@ struct LoRaConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.loRaConfig == nil {
Logger.mesh.info("⚙️ Empty or expired lora config requesting via PKI admin")
_ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
if connectedNode.user != nil && node.user != nil {
_ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty lora config")
_ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

View file

@ -66,7 +66,7 @@ struct AmbientLightingConfig: View {
al.blue = UInt32(components.blue * 255)
}
let adminMessageId = bleManager.saveAmbientLightingModuleConfig(config: al, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveAmbientLightingModuleConfig(config: al, fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -96,12 +96,11 @@ struct AmbientLightingConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.ambientLightingConfig == nil {
Logger.mesh.info("⚙️ Empty or expired ambient lighting module config requesting via PKI admin")
_ = bleManager.requestAmbientLightingConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestAmbientLightingConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty ambient lighting module config")
_ = bleManager.requestAmbientLightingConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

View file

@ -79,6 +79,7 @@ struct CannedMessagesConfig: View {
totalBytes = messages.utf8.count
}
hasMessagesChanges = true
hasChanges = true
}
.foregroundColor(.gray)
}
@ -201,7 +202,7 @@ struct CannedMessagesConfig: View {
cmc.inputbrokerEventCw = InputEventChars(rawValue: inputbrokerEventCw)!.protoEnumValue()
cmc.inputbrokerEventCcw = InputEventChars(rawValue: inputbrokerEventCcw)!.protoEnumValue()
cmc.inputbrokerEventPress = InputEventChars(rawValue: inputbrokerEventPress)!.protoEnumValue()
let adminMessageId = bleManager.saveCannedMessageModuleConfig(config: cmc, fromUser: node!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveCannedMessageModuleConfig(config: cmc, fromUser: node!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -211,7 +212,7 @@ struct CannedMessagesConfig: View {
}
}
if hasMessagesChanges {
let adminMessageId = bleManager.saveCannedMessageModuleMessages(messages: messages, fromUser: node!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveCannedMessageModuleMessages(messages: messages, fromUser: node!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -244,12 +245,11 @@ struct CannedMessagesConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.cannedMessageConfig == nil {
Logger.mesh.info("⚙️ Empty or expired canned messages module config requesting via PKI admin")
_ = bleManager.requestCannedMessagesModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestCannedMessagesModuleConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty canned messages module config")
_ = bleManager.requestCannedMessagesModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

View file

@ -172,7 +172,7 @@ struct DetectionSensorConfig: View {
dsc.usePullup = self.usePullup
dsc.minimumBroadcastSecs = UInt32(self.minimumBroadcastSecs)
dsc.stateBroadcastSecs = UInt32(self.stateBroadcastSecs)
let adminMessageId = bleManager.saveDetectionSensorModuleConfig(config: dsc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveDetectionSensorModuleConfig(config: dsc, fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -202,12 +202,11 @@ struct DetectionSensorConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.detectionSensorConfig == nil {
Logger.mesh.info("⚙️ Empty or expired detection sensor module config requesting via PKI admin")
_ = bleManager.requestDetectionSensorModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestDetectionSensorModuleConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty detection sensor module config")
_ = bleManager.requestDetectionSensorModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

View file

@ -180,7 +180,7 @@ struct ExternalNotificationConfig: View {
enc.outputMs = UInt32(outputMilliseconds)
enc.usePwm = usePWM
enc.useI2SAsBuzzer = useI2SAsBuzzer
let adminMessageId = bleManager.saveExternalNotificationModuleConfig(config: enc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveExternalNotificationModuleConfig(config: enc, fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -210,12 +210,11 @@ struct ExternalNotificationConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.externalNotificationConfig == nil {
Logger.mesh.info("⚙️ Empty or expired external notificaiton module config requesting via PKI admin")
_ = bleManager.requestExternalNotificationModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestExternalNotificationModuleConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty external notificaiton module config")
_ = bleManager.requestExternalNotificationModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

View file

@ -112,7 +112,6 @@ struct MQTTConfig: View {
Label("I have read and understand the above. I voluntarily consent to the unencrypted transmission of my node data via MQTT.", systemImage: "hand.raised")
.foregroundColor(.gray)
.font(.callout)
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
@ -130,11 +129,11 @@ struct MQTTConfig: View {
Text("To comply with privacy laws like CCPA and GDPR, we avoid sharing exact location data. Instead, we use anonymized or approximate (imprecise) location information to protect your privacy.")
.foregroundColor(.gray)
.font(.callout)
Slider(value: $mapPositionPrecision, in: 11...14, step: 1) {
Slider(value: $mapPositionPrecision, in: 12...15, step: 1) {
} minimumValueLabel: {
Image(systemName: "minus")
} maximumValueLabel: {
Image(systemName: "plus")
} maximumValueLabel: {
Image(systemName: "minus")
}
Text(PositionPrecision(rawValue: Int(mapPositionPrecision))?.description ?? "")
.foregroundColor(.gray)
@ -269,7 +268,7 @@ struct MQTTConfig: View {
mqtt.mapReportingEnabled = self.mapReportingEnabled
mqtt.mapReportSettings.positionPrecision = UInt32(self.mapPositionPrecision)
mqtt.mapReportSettings.publishIntervalSecs = UInt32(self.mapPublishIntervalSecs)
let adminMessageId = bleManager.saveMQTTConfig(config: mqtt, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveMQTTConfig(config: mqtt, fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -361,12 +360,11 @@ struct MQTTConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.mqttConfig == nil {
Logger.mesh.info("⚙️ Empty or expired mqtt module config requesting via PKI admin")
_ = bleManager.requestMqttModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestMqttModuleConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty mqtt module config")
_ = bleManager.requestMqttModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}
@ -430,8 +428,13 @@ struct MQTTConfig: View {
self.tlsEnabled = node?.mqttConfig?.tlsEnabled ?? false
self.mqttConnected = bleManager.mqttProxyConnected
self.mapReportingEnabled = node?.mqttConfig?.mapReportingEnabled ?? false
self.mapPublishIntervalSecs = Int(node?.mqttConfig?.mapPublishIntervalSecs ?? 3600)
if node?.mqttConfig?.mapPublishIntervalSecs ?? 0 < 3600 {
self.mapPublishIntervalSecs = 3600
} else {
self.mapPublishIntervalSecs = Int(node?.mqttConfig?.mapPublishIntervalSecs ?? 3600)
}
self.mapPositionPrecision = Double(node?.mqttConfig?.mapPositionPrecision ?? 14)
self.mapReportingOptIn = UserDefaults.mapReportingOptIn
if mapPositionPrecision < 11 || mapPositionPrecision > 14 {
self.mapPositionPrecision = 14
self.hasChanges = true

View file

@ -69,12 +69,11 @@ struct PaxCounterConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.paxCounterConfig == nil {
Logger.mesh.info("⚙️ Empty or expired pax counter module config requesting via PKI admin")
_ = bleManager.requestPaxCounterModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestPaxCounterModuleConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty pax counter module config")
_ = bleManager.requestPaxCounterModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}
@ -101,8 +100,7 @@ struct PaxCounterConfig: View {
let adminMessageId = bleManager.savePaxcounterModuleConfig(
config: config,
fromUser: fromUser,
toUser: toUser,
adminIndex: connectedNode.myInfo?.adminIndex ?? 0
toUser: toUser
)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true

View file

@ -62,7 +62,7 @@ struct RangeTestConfig: View {
rtc.enabled = enabled
rtc.save = save
rtc.sender = UInt32(sender)
let adminMessageId = bleManager.saveRangeTestModuleConfig(config: rtc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveRangeTestModuleConfig(config: rtc, fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -92,12 +92,11 @@ struct RangeTestConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.rangeTestConfig == nil {
Logger.mesh.info("⚙️ Empty or expired range test module config requesting via PKI admin")
_ = bleManager.requestRangeTestModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestRangeTestModuleConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty range test module config")
_ = bleManager.requestRangeTestModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

View file

@ -53,7 +53,7 @@ struct RtttlConfig: View {
SaveConfigButton(node: node, hasChanges: $hasChanges) {
let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context)
if connectedNode != nil {
let adminMessageId = bleManager.saveRtttlConfig(ringtone: ringtone.trimmingCharacters(in: .whitespacesAndNewlines), fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveRtttlConfig(ringtone: ringtone.trimmingCharacters(in: .whitespacesAndNewlines), fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -83,12 +83,11 @@ struct RtttlConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.rtttlConfig == nil {
Logger.mesh.info("⚙️ Empty or expired ringtone module config requesting via PKI admin")
_ = bleManager.requestRtttlConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestRtttlConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty ringtone module config")
_ = bleManager.requestRtttlConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

View file

@ -116,7 +116,7 @@ struct SerialConfig: View {
sc.overrideConsoleSerialPort = overrideConsoleSerialPort
sc.mode = SerialModeTypes(rawValue: mode)!.protoEnumValue()
let adminMessageId = bleManager.saveSerialModuleConfig(config: sc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveSerialModuleConfig(config: sc, fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
@ -147,12 +147,11 @@ struct SerialConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.serialConfig == nil {
Logger.mesh.info("⚙️ Empty or expired serial module config requesting via PKI admin")
_ = bleManager.requestSerialModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestSerialModuleConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty serial module config")
_ = bleManager.requestSerialModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

View file

@ -118,7 +118,7 @@ struct StoreForwardConfig: View {
sfc.records = UInt32(self.records)
sfc.historyReturnMax = UInt32(self.historyReturnMax)
sfc.historyReturnWindow = UInt32(self.historyReturnWindow)
let adminMessageId = bleManager.saveStoreForwardModuleConfig(config: sfc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveStoreForwardModuleConfig(config: sfc, fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -148,12 +148,11 @@ struct StoreForwardConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.storeForwardConfig == nil {
Logger.mesh.info("⚙️ Empty or expired store & forward module config requesting via PKI admin")
_ = bleManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty store & forward module config")
_ = bleManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

View file

@ -115,7 +115,7 @@ struct TelemetryConfig: View {
tc.powerMeasurementEnabled = powerMeasurementEnabled
tc.powerUpdateInterval = UInt32(powerUpdateInterval)
tc.powerScreenEnabled = powerScreenEnabled
let adminMessageId = bleManager.saveTelemetryModuleConfig(config: tc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
let adminMessageId = bleManager.saveTelemetryModuleConfig(config: tc, fromUser: connectedNode!.user!, toUser: node!.user!)
if adminMessageId > 0 {
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
@ -145,12 +145,11 @@ struct TelemetryConfig: View {
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.telemetryConfig == nil {
Logger.mesh.info("⚙️ Empty or expired telemetry module config requesting via PKI admin")
_ = bleManager.requestTelemetryModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
_ = bleManager.requestTelemetryModuleConfig(fromUser: connectedNode.user!, toUser: node.user!)
}
} else {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin, empty telemetry module config")
_ = bleManager.requestTelemetryModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0)
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}

Some files were not shown because too many files have changed in this diff Show more