Merge remote-tracking branch 'refs/remotes/origin/main'

This commit is contained in:
Garth Vander Houwen 2025-06-30 08:37:39 -07:00
commit a8a00a9605
19 changed files with 843 additions and 159 deletions

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

View file

@ -1329,6 +1329,9 @@
}
}
},
"• %@" : {
"shouldTranslate" : false
},
"< 1%" : {
"localizations" : {
"it" : {
@ -1693,7 +1696,7 @@
}
}
},
"A channel index of 0 indicates the primary channel where all broadcast packets are sent from." : {
"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." : {
},
"A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key." : {
@ -1757,7 +1760,10 @@
}
}
},
"A red lock with a slash means the channel is not securely encrypted, it uses either no key at all or a 1 byte known key. Traffic on this channel is easily intercepted." : {
"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." : {
},
"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." : {
},
"A Trace Route was sent, no response has been received." : {
@ -1781,6 +1787,9 @@
}
}
}
},
"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." : {
},
"About" : {
"localizations" : {
@ -13641,6 +13650,9 @@
}
}
}
},
"Hard Reset" : {
},
"Hardware" : {
"localizations" : {
@ -14980,6 +14992,9 @@
}
}
}
},
"In addition to Config, Keys and BLE bonds will be wiped" : {
},
"Include" : {
"localizations" : {
@ -15039,7 +15054,7 @@
}
}
},
"incomplete" : {
"Incomplete" : {
"localizations" : {
"de" : {
"stringUnit" : {
@ -16439,6 +16454,9 @@
}
}
}
},
"LoRa Config Changes:" : {
},
"LoRa config received: %@" : {
"localizations" : {
@ -22975,6 +22993,9 @@
}
}
}
},
"Provide Confirmation" : {
},
"Public Key" : {
"localizations" : {
@ -26377,6 +26398,7 @@
}
},
"Send a Direct Message" : {
"extractionState" : "stale",
"localizations" : {
"it" : {
"stringUnit" : {
@ -26465,6 +26487,7 @@
}
},
"Send a message to a certain meshtastic node" : {
"extractionState" : "stale",
"localizations" : {
"it" : {
"stringUnit" : {
@ -28068,6 +28091,9 @@
}
}
}
},
"Show a confirmation dialog before performing the factory reset" : {
},
"Show alerts" : {
"localizations" : {
@ -28238,6 +28264,7 @@
}
},
"Shows information for the Lora radio connected via bluetooth. You can swipe left to disconnect the radio and long press start the live activity." : {
"extractionState" : "stale",
"localizations" : {
"it" : {
"stringUnit" : {
@ -28252,6 +28279,9 @@
}
}
}
},
"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." : {
},
"Shut Down" : {
"localizations" : {

View file

@ -59,12 +59,12 @@
B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399E8A32B6F486400E4488E /* RetryButton.swift */; };
B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; };
BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */; };
BC47C2EF2CE0017D008245CA /* MessageNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC47C2EE2CE0017D008245CA /* MessageNodeIntent.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 */; };
@ -101,6 +101,7 @@
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 */; };
@ -329,13 +330,13 @@
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>"; };
BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactIntent.swift; sourceTree = "<group>"; };
BC47C2EE2CE0017D008245CA /* MessageNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageNodeIntent.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>"; };
@ -380,6 +381,7 @@
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>"; };
@ -702,7 +704,6 @@
BCE2D3C82C7C377F008E6199 /* FactoryResetNodeIntent.swift */,
BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */,
BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */,
BC47C2EE2CE0017D008245CA /* MessageNodeIntent.swift */,
BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */,
);
path = AppIntents;
@ -1057,6 +1058,7 @@
DD3CC6BD28E4CD9800FA9159 /* BatteryGauge.swift */,
DD3CC24B2C498D6C001BD3A2 /* BatteryCompact.swift */,
DD457187293C7E63000C49FB /* BLESignalStrengthIndicator.swift */,
DD1BEF512E08E9AE0090CE24 /* ChannelLock.swift */,
DD47E3D526F17ED900029299 /* CircleText.swift */,
DDF924C926FBB953009FE055 /* ConnectedDevice.swift */,
DDD94A4F2845C8F5004A87A0 /* DateTimeText.swift */,
@ -1076,6 +1078,7 @@
DDC2E1A526CEB32B0042C5E4 /* Helpers */ = {
isa = PBXGroup;
children = (
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */,
DDD43FE12A78C86B0083A3E9 /* Mqtt */,
DDAF8C5226EB1DF10058C060 /* BLEManager.swift */,
DD1BEF492E0292220090CE24 /* KeychainHelper.swift */,
@ -1384,13 +1387,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 */,
@ -1486,6 +1489,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 */,
@ -1824,7 +1828,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.8;
MARKETING_VERSION = 2.6.10;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
@ -1857,7 +1861,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.8;
MARKETING_VERSION = 2.6.10;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
@ -1888,7 +1892,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.6.8;
MARKETING_VERSION = 2.6.10;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1920,7 +1924,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.6.8;
MARKETING_VERSION = 2.6.10;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

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

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

@ -725,7 +725,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
let message = CocoaMQTTMessage(topic: decodedInfo.mqttClientProxyMessage.topic, payload: [UInt8](decodedInfo.mqttClientProxyMessage.data), retained: decodedInfo.mqttClientProxyMessage.retained)
mqttManager.mqttClientProxy?.publish(message)
} else if decodedInfo.payloadVariant == FromRadio.OneOf_PayloadVariant.clientNotification(decodedInfo.clientNotification) {
var path = "meshtastic:///settings/debugLogs"
if decodedInfo.clientNotification.hasReplyID {
/// Set Sent bool on TraceRouteEntity to false if we got rate limited
@ -740,8 +739,9 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
let nsError = error as NSError
Logger.data.error("💥 [TraceRouteEntity] Error Updating Core Data: \(nsError, privacy: .public)")
}
} else if decodedInfo.clientNotification.message.starts(with: "You Device is configured with a low entropy") || decodedInfo.clientNotification.message.starts(with: "Compromised keys detected")
|| decodedInfo.clientNotification.message.starts(with: "Remote device"){
}
if decodedInfo.clientNotification.payloadVariant == ClientNotification.OneOf_PayloadVariant.lowEntropyKey(decodedInfo.clientNotification.lowEntropyKey) ||
decodedInfo.clientNotification.payloadVariant == ClientNotification.OneOf_PayloadVariant.duplicatedPublicKey(decodedInfo.clientNotification.duplicatedPublicKey) {
path = "meshtastic:///settings/security"
}
}
@ -1755,7 +1755,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
return 0
}
public func saveChannelSet(base64UrlString: String, addChannels: Bool = false) -> Bool {
public func saveChannelSet(base64UrlString: String, addChannels: Bool = false, okToMQTT: Bool = false) -> Bool {
if isConnected {
var i: Int32 = 0
@ -1837,6 +1837,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
// Save the LoRa Config and the device will reboot
var adminPacket = AdminMessage()
adminPacket.setConfig.lora = channelSet.loraConfig
adminPacket.setConfig.lora.configOkToMqtt = okToMQTT // Preserve users okToMQTT choice
var meshPacket: MeshPacket = MeshPacket()
meshPacket.to = UInt32(connectedPeripheral.num)
meshPacket.from = UInt32(connectedPeripheral.num)

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

@ -20,7 +20,6 @@ struct MeshtasticAppleApp: App {
@State var incomingUrl: URL?
@State var channelSettings: String?
@State var addChannels = false
public var minimumContactVersion = "2.6.9"
init() {
let persistenceController = PersistenceController.shared
@ -44,20 +43,31 @@ struct MeshtasticAppleApp: App {
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
self.saveChannels = false
if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/v/#") == true {
handleContactUrl(url: self.incomingUrl!)
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
@ -74,7 +84,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 {
@ -85,7 +95,7 @@ struct MeshtasticAppleApp: App {
Logger.mesh.debug("Some sort of URL was received \(url, privacy: .public)")
self.incomingUrl = url
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
handleContactUrl(url: url)
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
@ -102,7 +112,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)
@ -141,77 +151,9 @@ struct MeshtasticAppleApp: App {
Logger.services.error("🍎 [App] Apple must have changed something")
}
}
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environmentObject(appState)
.environmentObject(BLEManager.shared)
}
func handleContactUrl(url: URL) {
let supportedVersion = UserDefaults.firmwareVersion == "0.0.0" || self.minimumContactVersion.compare(UserDefaults.firmwareVersion, options: .numeric) == .orderedAscending || minimumContactVersion.compare(UserDefaults.firmwareVersion, options: .numeric) == .orderedSame
if !supportedVersion {
// Show an alert letting the user know they need to upgrade their firmware to use the contact import.
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
))
// Present the alert
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: "#")
// Extract contact information from the URL
if let contactData = components.last {
let decodedString = contactData.base64urlToBase64()
if let decodedData = Data(base64Encoded: decodedString) {
do {
let contact = try MeshtasticProtobufs.SharedContact(serializedBytes: decodedData)
// Show an alert to confirm adding the contact
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.shared.addContactFromURL(base64UrlString: contactData)
Logger.services.debug("Contact added from URL: \(success ? "success" : "failed")")
}
))
alertController.addAction(UIAlertAction(
title: "No",
style: .cancel,
handler: nil
))
// Present the alert
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)")
// Show error alert to user
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

@ -8,6 +8,19 @@ 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)
@ -1367,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
@ -1498,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

@ -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,25 +21,46 @@ struct ChannelsHelp: View {
CircleText(text: String(0), color: .accentColor)
.brightness(0.2)
.offset(y: -10)
Text("A channel index of 0 indicates the primary channel where all broadcast packets are sent from.")
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(.bottom)
.padding(.leading)
.padding(.trailing, 7)
.foregroundColor(Color.green)
.font(.largeTitle)
.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.slash.fill")
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(.largeTitle)
Text("A red lock with a slash means the channel is not securely encrypted, it uses either no key at all or a 1 byte known key. Traffic on this channel is easily intercepted.")
.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)
}

View file

@ -58,13 +58,7 @@ struct ChannelList: View {
VStack(alignment: .leading) {
HStack {
if channel.psk?.hexDescription.count ?? 0 < 3 {
Image(systemName: "lock.slash.fill")
.foregroundColor(.red)
} else {
Image(systemName: "lock.fill")
.foregroundColor(.green)
}
ChannelLock(channel: channel)
if channel.name?.isEmpty ?? false {
if channel.role == 1 {
Text(String("PrimaryChannel").camelCaseToWords())
@ -173,7 +167,7 @@ struct ChannelList: View {
}
.sheet(isPresented: $showingHelp) {
ChannelsHelp()
.presentationDetents([.medium, .large])
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.safeAreaInset(edge: .bottom, alignment: .leading) {

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

@ -79,7 +79,7 @@ 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

@ -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,13 +125,7 @@ struct Channels: View {
.brightness(0.1)
VStack {
HStack {
if channel.psk?.hexDescription.count ?? 0 < 3 {
Image(systemName: "lock.slash.fill")
.foregroundColor(.red)
} else {
Image(systemName: "lock.fill")
.foregroundColor(.green)
}
ChannelLock(channel: channel)
if channel.name?.isEmpty ?? false {
if channel.role == 1 {
Text(String("PrimaryChannel").camelCaseToWords()).font(.headline)
@ -246,6 +241,7 @@ struct Channels: View {
#endif
}
}
if node?.myInfo?.channels?.array.count ?? 0 < 8 && node != nil {
Button {
@ -286,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

@ -230,6 +230,9 @@ struct SecurityConfig: View {
name: "\(bleManager.connectedPeripheral?.shortName ?? "?")"
)
})
.onChange(of: node) { _, newNode in
setSecurityValues()
}
.onChange(of: isManaged) { _, newIsManaged in
if newIsManaged != node?.securityConfig?.isManaged { hasChanges = true }
}
@ -349,6 +352,14 @@ struct SecurityConfig: View {
}
}
hasChanges = false
if keyUpdated {
if !bleManager.sendReboot(
fromUser: fromUser,
toUser: toUser
) {
Logger.mesh.warning("Reboot Failed")
}
}
goBack()
}
}

View file

@ -5,16 +5,24 @@
// Copyright(c) Garth Vander Houwen 7/13/22.
//
import SwiftUI
import CoreData
import OSLog
import MeshtasticProtobufs
struct SaveChannelQRCode: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) var context
var channelSetLink: String
let channelSetLink: String
var addChannels: Bool = false
var bleManager: BLEManager
@State var showError: Bool = false
@State var connectedToDevice = false
@State private var showError: Bool = false
@State private var errorMessage: String = ""
@State private var connectedToDevice: Bool = false
@State private var loraChanges: [String] = []
@State private var okToMQTT: Bool = false
var body: some View {
VStack {
@ -26,20 +34,50 @@ struct SaveChannelQRCode: View {
.font(.title3)
.padding()
if !loraChanges.isEmpty {
VStack(alignment: .leading) {
Text("LoRa Config Changes:")
.font(.headline)
.padding(.bottom, 5)
ForEach(loraChanges, id: \.self) { change in
Text("\(change)")
.font(.callout)
.foregroundColor(.orange)
}
}
.padding()
}
if showError {
Text("Channels being added from the QR code did not save. When adding channels the names must be unique.")
Text(errorMessage.isEmpty ? "Channels being added from the QR code did not save. When adding channels the names must be unique." : errorMessage)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.red)
.font(.callout)
.padding()
}
HStack {
if !showError {
Button {
let success = bleManager.saveChannelSet(base64UrlString: channelSetLink, addChannels: addChannels)
// Extract channel data if it's a full URL
let channelData: String
if channelSetLink.hasPrefix("http") || channelSetLink.hasPrefix("meshtastic://") {
guard let extractedData = extractChannelDataFromURL(channelSetLink) else {
Logger.data.error("Failed to extract channel data from URL during save: \(channelSetLink)")
errorMessage = "Invalid channel URL format"
showError = true
return
}
channelData = extractedData
} else {
channelData = channelSetLink
}
let success = bleManager.saveChannelSet(base64UrlString: channelData, addChannels: addChannels, okToMQTT: okToMQTT)
if success {
dismiss()
} else {
errorMessage = "Failed to save channel configuration"
showError = true
}
} label: {
@ -50,24 +88,23 @@ struct SaveChannelQRCode: View {
.controlSize(.large)
.padding()
.disabled(!connectedToDevice)
#if targetEnvironment(macCatalyst)
Button {
dismiss()
} label: {
Label("Cancel", systemImage: "xmark")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
#endif
#if targetEnvironment(macCatalyst)
Button {
dismiss()
} label: {
Label("Cancel", systemImage: "xmark")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
#endif
} else {
Button {
dismiss()
} label: {
Label("Cancel", systemImage: "xmark")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
@ -77,7 +114,226 @@ struct SaveChannelQRCode: View {
}
}
.onAppear {
Logger.data.info("Ch set link \(channelSetLink)")
connectedToDevice = bleManager.connectToPreferredPeripheral()
fetchLoRaConfigChanges()
}
}
private func extractChannelDataFromURL(_ urlString: String) -> String? {
Logger.data.info("Extracting channel data from URL: \(urlString)")
if let url = URL(string: urlString) {
// Get the fragment (part after #)
if let fragment = url.fragment, !fragment.isEmpty {
Logger.data.info("Extracted fragment from URL: \(fragment)")
return fragment
}
}
// Fallback: manually extract everything after the last #
if let hashIndex = urlString.lastIndex(of: "#") {
let startIndex = urlString.index(after: hashIndex)
let channelData = String(urlString[startIndex...])
if !channelData.isEmpty {
Logger.data.info("Extracted channel data manually: \(channelData)")
return channelData
}
}
Logger.data.error("Failed to extract channel data from URL: \(urlString)")
return nil
}
private func fetchLoRaConfigChanges() {
var currentLoRaConfig: Config.LoRaConfig?
// First, extract the actual channel data from the URL if it's a full URL
let channelData: String
if channelSetLink.hasPrefix("http") || channelSetLink.hasPrefix("meshtastic://") {
guard let extractedData = extractChannelDataFromURL(channelSetLink) else {
Logger.data.error("Failed to extract channel data from URL: \(channelSetLink)")
errorMessage = "Invalid channel URL format"
showError = true
return
}
channelData = extractedData
} else {
// Assume it's already the base64 data
channelData = channelSetLink
}
Logger.data.info("Processing channel data: \(channelData)")
// Fetch current LoRa config from Core Data
let fetchRequest = NodeInfoEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "num == %lld", Int64(bleManager.connectedPeripheral?.num ?? 0))
do {
let nodes = try context.fetch(fetchRequest)
if let node = nodes.first {
currentLoRaConfig = node.loRaConfig?.toProto()
}
} catch {
Logger.data.error("Failed to fetch NodeInfoEntity: \(error.localizedDescription, privacy: .public)")
}
// Decode base64url string
let decodedString = channelData.base64urlToBase64()
guard let decodedData = Data(base64Encoded: decodedString) else {
Logger.data.error("Invalid base64 for ChannelSet data: \(channelData, privacy: .public)")
errorMessage = "Invalid channel data format"
showError = true
return
}
do {
let channelSet = try ChannelSet(serializedBytes: decodedData)
let newLoRaConfig = channelSet.loraConfig
var changes: [String] = []
// Preserve user's current okToMQTT setting
okToMQTT = currentLoRaConfig?.configOkToMqtt ?? false
if let current = currentLoRaConfig {
// Compare each field and track changes
if current.hopLimit != newLoRaConfig.hopLimit {
changes.append("Hop Limit: \(current.hopLimit) -> \(newLoRaConfig.hopLimit)")
}
if current.region != newLoRaConfig.region {
let currentRegionDesc = RegionCodes(rawValue: Int(current.region.rawValue))?.description ?? "Unknown"
let newRegionDesc = RegionCodes(rawValue: Int(newLoRaConfig.region.rawValue))?.description ?? "Unknown"
changes.append("Region: \(currentRegionDesc) -> \(newRegionDesc)")
}
if current.modemPreset != newLoRaConfig.modemPreset {
let currentPresetDesc = ModemPresets(rawValue: Int(current.modemPreset.rawValue))?.description ?? "Unknown"
let newPresetDesc = ModemPresets(rawValue: Int(newLoRaConfig.modemPreset.rawValue))?.description ?? "Unknown"
changes.append("Modem Preset: \(currentPresetDesc) -> \(newPresetDesc)")
}
if current.usePreset != newLoRaConfig.usePreset {
changes.append("Use Preset: \(current.usePreset) -> \(newLoRaConfig.usePreset)")
}
if current.txEnabled != newLoRaConfig.txEnabled {
changes.append("Transmit Enabled: \(current.txEnabled) -> \(newLoRaConfig.txEnabled)")
}
if current.txPower != newLoRaConfig.txPower {
changes.append("Transmit Power: \(current.txPower)dBm -> \(newLoRaConfig.txPower)dBm")
}
if current.channelNum != newLoRaConfig.channelNum {
changes.append("Channel Number: \(current.channelNum) -> \(newLoRaConfig.channelNum)")
}
if current.bandwidth != newLoRaConfig.bandwidth {
changes.append("Bandwidth: \(current.bandwidth) -> \(newLoRaConfig.bandwidth)")
}
if current.codingRate != newLoRaConfig.codingRate {
changes.append("Coding Rate: \(current.codingRate) -> \(newLoRaConfig.codingRate)")
}
if current.spreadFactor != newLoRaConfig.spreadFactor {
changes.append("Spread Factor: \(current.spreadFactor) -> \(newLoRaConfig.spreadFactor)")
}
if current.sx126XRxBoostedGain != newLoRaConfig.sx126XRxBoostedGain {
changes.append("RX Boosted Gain: \(current.sx126XRxBoostedGain) -> \(newLoRaConfig.sx126XRxBoostedGain)")
}
if current.overrideFrequency != newLoRaConfig.overrideFrequency {
changes.append("Override Frequency: \(current.overrideFrequency) -> \(newLoRaConfig.overrideFrequency)")
}
if current.ignoreMqtt != newLoRaConfig.ignoreMqtt {
changes.append("Ignore MQTT: \(current.ignoreMqtt) -> \(newLoRaConfig.ignoreMqtt)")
}
} else {
// Compare against default values when no current config exists
let defaultConfig = getDefaultLoRaConfig()
if newLoRaConfig.hopLimit != defaultConfig.hopLimit {
changes.append("Hop Limit: \(defaultConfig.hopLimit) -> \(newLoRaConfig.hopLimit)")
}
if newLoRaConfig.region != defaultConfig.region {
let newRegionDesc = RegionCodes(rawValue: Int(newLoRaConfig.region.rawValue))?.description ?? "Unknown"
changes.append("Region: Unset -> \(newRegionDesc)")
}
if newLoRaConfig.modemPreset != defaultConfig.modemPreset {
let newPresetDesc = ModemPresets(rawValue: Int(newLoRaConfig.modemPreset.rawValue))?.description ?? "Unknown"
changes.append("Modem Preset: Long Fast -> \(newPresetDesc)")
}
if newLoRaConfig.usePreset != defaultConfig.usePreset {
changes.append("Use Preset: \(defaultConfig.usePreset) -> \(newLoRaConfig.usePreset)")
}
if newLoRaConfig.txEnabled != defaultConfig.txEnabled {
changes.append("Transmit Enabled: \(defaultConfig.txEnabled) -> \(newLoRaConfig.txEnabled)")
}
if newLoRaConfig.txPower != defaultConfig.txPower {
changes.append("Transmit Power: \(defaultConfig.txPower)dBm -> \(newLoRaConfig.txPower)dBm")
}
if newLoRaConfig.channelNum != defaultConfig.channelNum {
changes.append("Channel Number: \(defaultConfig.channelNum) -> \(newLoRaConfig.channelNum)")
}
if newLoRaConfig.bandwidth != defaultConfig.bandwidth {
changes.append("Bandwidth: \(defaultConfig.bandwidth) -> \(newLoRaConfig.bandwidth)")
}
if newLoRaConfig.codingRate != defaultConfig.codingRate {
changes.append("Coding Rate: \(defaultConfig.codingRate) -> \(newLoRaConfig.codingRate)")
}
if newLoRaConfig.spreadFactor != defaultConfig.spreadFactor {
changes.append("Spread Factor: \(defaultConfig.spreadFactor) -> \(newLoRaConfig.spreadFactor)")
}
if newLoRaConfig.sx126XRxBoostedGain != defaultConfig.sx126XRxBoostedGain {
changes.append("RX Boosted Gain: \(defaultConfig.sx126XRxBoostedGain) -> \(newLoRaConfig.sx126XRxBoostedGain)")
}
if newLoRaConfig.overrideFrequency != defaultConfig.overrideFrequency {
changes.append("Override Frequency: \(defaultConfig.overrideFrequency) -> \(newLoRaConfig.overrideFrequency)")
}
if newLoRaConfig.ignoreMqtt != defaultConfig.ignoreMqtt {
changes.append("Ignore MQTT: \(defaultConfig.ignoreMqtt) -> \(newLoRaConfig.ignoreMqtt)")
}
}
loraChanges = changes
} catch {
Logger.data.error("Failed to decode ChannelSet: \(error.localizedDescription, privacy: .public)")
errorMessage = "Failed to decode channel configuration"
showError = true
}
}
private func getDefaultLoRaConfig() -> Config.LoRaConfig {
var config = Config.LoRaConfig()
config.hopLimit = 3
config.region = .unset
config.modemPreset = .longFast
config.usePreset = true
config.txEnabled = true
config.txPower = 0
config.channelNum = 0
config.bandwidth = 0
config.codingRate = 0
config.spreadFactor = 0
config.sx126XRxBoostedGain = false
config.overrideFrequency = 0.0
config.ignoreMqtt = false
config.configOkToMqtt = false
return config
}
}
extension LoRaConfigEntity {
func toProto() -> Config.LoRaConfig {
var config = Config.LoRaConfig()
config.hopLimit = UInt32(self.hopLimit)
config.region = Config.LoRaConfig.RegionCode(rawValue: Int(self.regionCode)) ?? .unset
config.modemPreset = Config.LoRaConfig.ModemPreset(rawValue: Int(self.modemPreset)) ?? .longFast
config.usePreset = self.usePreset
config.txEnabled = self.txEnabled
config.txPower = Int32(self.txPower)
config.channelNum = UInt32(self.channelNum)
config.bandwidth = UInt32(self.bandwidth)
config.codingRate = UInt32(self.codingRate)
config.spreadFactor = UInt32(self.spreadFactor)
config.sx126XRxBoostedGain = self.sx126xRxBoostedGain
config.overrideFrequency = self.overrideFrequency
config.ignoreMqtt = self.ignoreMqtt
config.configOkToMqtt = self.okToMqtt
return config
}
}

View file

@ -49,6 +49,7 @@ struct ShareChannels: View {
var node: NodeInfoEntity?
@State private var channelsUrl = "https://www.meshtastic.org/e/#"
var qrCodeImage = QrCodeImage()
@State private var showingHelp = false
var body: some View {
@ -82,13 +83,7 @@ struct ShareChannels: View {
.toggleStyle(.switch)
.labelsHidden()
Text(((channel.name!.isEmpty ? "Primary" : channel.name) ?? "Primary").camelCaseToWords())
if channel.psk?.hexDescription.count ?? 0 < 3 {
Image(systemName: "lock.slash.fill")
.foregroundColor(.red)
} else {
Image(systemName: "lock.fill")
.foregroundColor(.green)
}
ChannelLock(channel: channel)
} else if channel.index == 1 && channel.role > 0 {
Toggle("Channel 1 Included", isOn: $includeChannel1)
.toggleStyle(.switch)
@ -216,16 +211,39 @@ struct ShareChannels: View {
.resizable()
.scaledToFit()
.frame(
minWidth: smallest * (UIDevice.current.userInterfaceIdiom == .phone ? 0.8 : 0.6),
maxWidth: smallest * (UIDevice.current.userInterfaceIdiom == .phone ? 0.8 : 0.6),
minHeight: smallest * (UIDevice.current.userInterfaceIdiom == .phone ? 0.8 : 0.6),
maxHeight: smallest * (UIDevice.current.userInterfaceIdiom == .phone ? 0.8 : 0.6),
minWidth: smallest * (UIDevice.current.userInterfaceIdiom == .phone ? 0.75 : 0.6),
maxWidth: smallest * (UIDevice.current.userInterfaceIdiom == .phone ? 0.75 : 0.6),
minHeight: smallest * (UIDevice.current.userInterfaceIdiom == .phone ? 0.75 : 0.6),
maxHeight: smallest * (UIDevice.current.userInterfaceIdiom == .phone ? 0.75 : 0.6),
alignment: .top
)
}
}
}
}
.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("Generate QR Code")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing: