mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge pull request #461 from meshtastic/2.2.19_Working_Changes
2.2.19 Working Changes
This commit is contained in:
commit
d63187ecd4
14 changed files with 621 additions and 537 deletions
|
|
@ -1491,7 +1491,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.18;
|
||||
MARKETING_VERSION = 2.2.19;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -1525,7 +1525,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.18;
|
||||
MARKETING_VERSION = 2.2.19;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -1647,7 +1647,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.18;
|
||||
MARKETING_VERSION = 2.2.19;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1680,7 +1680,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.18;
|
||||
MARKETING_VERSION = 2.2.19;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -96,3 +96,27 @@ func positionToCsvFile(positions: [PositionEntity]) -> String {
|
|||
}
|
||||
return csvString
|
||||
}
|
||||
|
||||
|
||||
func routeToCsvFile(locations: [LocationEntity]) -> String {
|
||||
var csvString: String = ""
|
||||
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current)
|
||||
let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "")
|
||||
// Create Position Header
|
||||
csvString = "Id, Latitude, Longitude, Altitude, Speed, Heading"
|
||||
for loc in locations {
|
||||
csvString += "\n"
|
||||
csvString += String(loc.id)
|
||||
csvString += ", "
|
||||
csvString += String((loc.latitude ?? 0))
|
||||
csvString += ", "
|
||||
csvString += String(loc.longitude ?? 0)
|
||||
csvString += ", "
|
||||
csvString += String(loc.altitude)
|
||||
csvString += ", "
|
||||
csvString += String(loc.speed)
|
||||
csvString += ", "
|
||||
csvString += String(loc.heading)
|
||||
}
|
||||
return csvString
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
extension NodeInfoEntity {
|
||||
|
||||
|
|
@ -37,4 +38,22 @@ extension NodeInfoEntity {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func createNodeInfo(num: Int64, context: NSManagedObjectContext) -> NodeInfoEntity {
|
||||
|
||||
let newNode = NodeInfoEntity(context: context)
|
||||
newNode.id = Int64(num)
|
||||
newNode.num = Int64(num)
|
||||
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"
|
||||
newNode.user = newUser
|
||||
return newNode
|
||||
}
|
||||
|
|
|
|||
|
|
@ -649,7 +649,10 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
|
|||
var routeString = "You --> "
|
||||
var hopNodes: [TraceRouteHopEntity] = []
|
||||
for node in routingMessage.route {
|
||||
let hopNode = getNodeInfo(id: Int64(node), context: context!)
|
||||
var hopNode = getNodeInfo(id: Int64(node), context: context!)
|
||||
if hopNode == nil {
|
||||
hopNode = createNodeInfo(num: Int64(node), context: context!)
|
||||
}
|
||||
let traceRouteHop = TraceRouteHopEntity(context: context!)
|
||||
traceRouteHop.time = Date()
|
||||
if hopNode?.hasPositions ?? false {
|
||||
|
|
@ -1107,6 +1110,28 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
|
|||
return false
|
||||
}
|
||||
|
||||
public func sendEnterDfuMode(fromUser: UserEntity, toUser: UserEntity) -> Bool {
|
||||
var adminPacket = AdminMessage()
|
||||
adminPacket.enterDfuModeRequest = true
|
||||
var meshPacket: MeshPacket = MeshPacket()
|
||||
meshPacket.to = UInt32(toUser.num)
|
||||
meshPacket.from = UInt32(fromUser.num)
|
||||
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
|
||||
meshPacket.priority = MeshPacket.Priority.reliable
|
||||
meshPacket.wantAck = true
|
||||
meshPacket.channel = UInt32(0)
|
||||
var dataMessage = DataMessage()
|
||||
dataMessage.payload = try! adminPacket.serializedData()
|
||||
dataMessage.portnum = PortNum.adminApp
|
||||
meshPacket.decoded = dataMessage
|
||||
automaticallyReconnect = false
|
||||
let messageDescription = "🚀 Sent enter DFU mode Admin Message to: \(toUser.longName ?? "unknown".localized) from: \(fromUser.longName ?? "unknown".localized)"
|
||||
if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription, fromUser: fromUser, toUser: toUser) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public func sendFactoryReset(fromUser: UserEntity, toUser: UserEntity) -> Bool {
|
||||
var adminPacket = AdminMessage()
|
||||
adminPacket.factoryReset = 5
|
||||
|
|
@ -2257,6 +2282,33 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
|
|||
return false
|
||||
}
|
||||
|
||||
public func requestStoreAndForwardModuleConfig(fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32) -> Bool {
|
||||
|
||||
var adminPacket = AdminMessage()
|
||||
adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.storeforwardConfig
|
||||
|
||||
var meshPacket: MeshPacket = MeshPacket()
|
||||
meshPacket.to = UInt32(toUser.num)
|
||||
meshPacket.from = UInt32(fromUser.num)
|
||||
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
|
||||
meshPacket.priority = MeshPacket.Priority.reliable
|
||||
meshPacket.channel = UInt32(adminIndex)
|
||||
meshPacket.wantAck = true
|
||||
|
||||
var dataMessage = DataMessage()
|
||||
dataMessage.payload = try! adminPacket.serializedData()
|
||||
dataMessage.portnum = PortNum.adminApp
|
||||
dataMessage.wantResponse = true
|
||||
|
||||
meshPacket.decoded = dataMessage
|
||||
|
||||
let messageDescription = "🛎️ Requested Store and Forward Module Config on admin channel \(adminIndex) for node: \(toUser.longName ?? "unknown".localized)"
|
||||
if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription, fromUser: fromUser, toUser: toUser) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public func requestTelemetryModuleConfig(fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32) -> Bool {
|
||||
|
||||
var adminPacket = AdminMessage()
|
||||
|
|
|
|||
|
|
@ -192,29 +192,31 @@ func deviceMetadataPacket (metadata: DeviceMetadata, fromNum: Int64, context: NS
|
|||
guard let fetchedNode = try context.fetch(fetchedNodeRequest) as? [NodeInfoEntity] else {
|
||||
return
|
||||
}
|
||||
let newMetadata = DeviceMetadataEntity(context: context)
|
||||
newMetadata.deviceStateVersion = Int32(metadata.deviceStateVersion)
|
||||
newMetadata.canShutdown = metadata.canShutdown
|
||||
newMetadata.hasWifi = metadata.hasWifi_p
|
||||
newMetadata.hasBluetooth = metadata.hasBluetooth_p
|
||||
newMetadata.hasEthernet = metadata.hasEthernet_p
|
||||
newMetadata.role = Int32(metadata.role.rawValue)
|
||||
newMetadata.positionFlags = Int32(metadata.positionFlags)
|
||||
// Swift does strings weird, this does work to get the version without the github hash
|
||||
let lastDotIndex = metadata.firmwareVersion.lastIndex(of: ".")
|
||||
var version = metadata.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: metadata.firmwareVersion))]
|
||||
version = version.dropLast()
|
||||
newMetadata.firmwareVersion = String(version)
|
||||
if fetchedNode.count > 0 {
|
||||
let newMetadata = DeviceMetadataEntity(context: context)
|
||||
newMetadata.deviceStateVersion = Int32(metadata.deviceStateVersion)
|
||||
newMetadata.canShutdown = metadata.canShutdown
|
||||
newMetadata.hasWifi = metadata.hasWifi_p
|
||||
newMetadata.hasBluetooth = metadata.hasBluetooth_p
|
||||
newMetadata.hasEthernet = metadata.hasEthernet_p
|
||||
newMetadata.role = Int32(metadata.role.rawValue)
|
||||
newMetadata.positionFlags = Int32(metadata.positionFlags)
|
||||
// Swift does strings weird, this does work to get the version without the github hash
|
||||
let lastDotIndex = metadata.firmwareVersion.lastIndex(of: ".")
|
||||
var version = metadata.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: metadata.firmwareVersion))]
|
||||
version = version.dropLast()
|
||||
newMetadata.firmwareVersion = String(version)
|
||||
fetchedNode[0].metadata = newMetadata
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("Failed to save device metadata")
|
||||
}
|
||||
print("💾 Updated Device Metadata from Admin App Packet For: \(fromNum)")
|
||||
} else {
|
||||
let newNode = createNodeInfo(num: Int64(fromNum), context: context)
|
||||
newNode.metadata = newMetadata
|
||||
}
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("Failed to save device metadata")
|
||||
}
|
||||
print("💾 Updated Device Metadata from Admin App Packet For: \(fromNum)")
|
||||
} catch {
|
||||
context.rollback()
|
||||
let nsError = error as NSError
|
||||
|
|
|
|||
|
|
@ -33,9 +33,7 @@ struct DeviceMetricsLog: View {
|
|||
GroupBox(label: Label("\(deviceMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) {
|
||||
|
||||
Chart {
|
||||
|
||||
ForEach(chartData, id: \.self) { point in
|
||||
|
||||
Plot {
|
||||
LineMark(
|
||||
x: .value("x", point.time!),
|
||||
|
|
@ -45,7 +43,7 @@ struct DeviceMetricsLog: View {
|
|||
.accessibilityLabel("Line Series")
|
||||
.accessibilityValue("X: \(point.time!), Y: \(point.batteryLevel)")
|
||||
.foregroundStyle(batteryChartColor)
|
||||
.interpolationMethod(.catmullRom(alpha: 1.0))
|
||||
.interpolationMethod(.cardinal)
|
||||
|
||||
Plot {
|
||||
PointMark(
|
||||
|
|
|
|||
|
|
@ -52,10 +52,6 @@ struct AboutMeshtastic: View {
|
|||
.font(.title2)
|
||||
|
||||
Text("Version: \(Bundle.main.appVersionLong) (\(Bundle.main.appBuild)) ")
|
||||
|
||||
Text(Bundle.main.copyright)
|
||||
.font(.system(size: 10, weight: .thin))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Section(header: Text("Project information")) {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ struct Channels: View {
|
|||
|
||||
var body: some View {
|
||||
|
||||
NavigationStack {
|
||||
VStack {
|
||||
List {
|
||||
if node != nil && node?.myInfo != nil {
|
||||
ForEach(node?.myInfo?.channels?.array as? [ChannelEntity] ?? [], id: \.self) { (channel: ChannelEntity) in
|
||||
|
|
@ -167,16 +167,17 @@ struct Channels: View {
|
|||
HStack(alignment: .top) {
|
||||
Text("Key")
|
||||
Spacer()
|
||||
TextField(
|
||||
"",
|
||||
text: $channelKey,
|
||||
axis: .vertical
|
||||
)
|
||||
.foregroundColor(Color.gray)
|
||||
.disabled(true)
|
||||
|
||||
Text(channelKey)
|
||||
.foregroundColor(Color.gray)
|
||||
.textSelection(.enabled)
|
||||
// TextField(
|
||||
// "",
|
||||
// text: $channelKey,
|
||||
// axis: .vertical
|
||||
// )
|
||||
// .foregroundColor(Color.gray)
|
||||
// .disabled(true)
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
Picker("Channel Role", selection: $channelRole) {
|
||||
if channelRole == 1 {
|
||||
Text("Primary").tag(1)
|
||||
|
|
@ -192,9 +193,6 @@ struct Channels: View {
|
|||
Toggle("Downlink Enabled", isOn: $downlink)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
}
|
||||
// .onSubmit {
|
||||
// validate(name: channelName)
|
||||
// }
|
||||
.onChange(of: channelName) { _ in
|
||||
hasChanges = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ struct StoreForwardConfig: View {
|
|||
Text("Remote administration for: \(node?.user?.longName ?? "Unknown")")
|
||||
.font(.title3)
|
||||
.onAppear {
|
||||
setDetectionSensorValues()
|
||||
setStoreAndForwardValues()
|
||||
}
|
||||
}
|
||||
} else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 {
|
||||
|
|
@ -140,13 +140,13 @@ struct StoreForwardConfig: View {
|
|||
if self.bleManager.context == nil {
|
||||
self.bleManager.context = context
|
||||
}
|
||||
setDetectionSensorValues()
|
||||
setStoreAndForwardValues()
|
||||
// Need to request a Detection Sensor Module Config from the remote node before allowing changes
|
||||
if bleManager.connectedPeripheral != nil && node?.detectionSensorConfig == nil {
|
||||
if bleManager.connectedPeripheral != nil && node?.storeForwardConfig == nil {
|
||||
print("empty store and forward module config")
|
||||
let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context)
|
||||
if node != nil && connectedNode != nil {
|
||||
_ = bleManager.requestDetectionSensorModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
|
||||
_ = bleManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -176,7 +176,7 @@ struct StoreForwardConfig: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
func setDetectionSensorValues() {
|
||||
func setStoreAndForwardValues() {
|
||||
self.enabled = (node?.storeForwardConfig?.enabled ?? false)
|
||||
self.heartbeat = (node?.storeForwardConfig?.heartbeat ?? true)
|
||||
self.records = Int(node?.storeForwardConfig?.records ?? 50)
|
||||
|
|
|
|||
|
|
@ -12,38 +12,131 @@ struct Firmware: View {
|
|||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
var node: NodeInfoEntity?
|
||||
@State var minimumVersion = "2.2.16"
|
||||
@State var minimumVersion = "2.2.17"
|
||||
@State var version = ""
|
||||
@State private var currentDevice: DeviceHardware?
|
||||
|
||||
@State private var latestStable: FirmwareRelease?
|
||||
@State private var latestAlpha: FirmwareRelease?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
|
||||
let supportedVersion = bleManager.connectedVersion == "0.0.0" || self.minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedSame
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
let deviceString = currentDevice?.hwModelSlug.replacingOccurrences(of: "_", with: "")
|
||||
|
||||
Text("Your Device Model: \(currentDevice?.displayName ?? "Unknown")")
|
||||
.font(.largeTitle)
|
||||
|
||||
HStack {
|
||||
VStack {
|
||||
Image(systemName: currentDevice?.activelySupported ?? false ? "checkmark.seal.fill" : "x.circle")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(currentDevice?.activelySupported ?? false ? .green : .red)
|
||||
Text( currentDevice?.activelySupported ?? false ? "Supported" : "Unsupported")
|
||||
.foregroundStyle(.gray)
|
||||
.font(.caption2)
|
||||
}
|
||||
Text("Device Model: \(currentDevice?.displayName ?? "Unknown")")
|
||||
.font(.largeTitle)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
VStack {
|
||||
Image(deviceString ?? "UNSET")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 200, height: 200)
|
||||
.frame(width: 300, height: 300)
|
||||
.cornerRadius(5)
|
||||
}
|
||||
Text("Current Firmware Version: \(bleManager.connectedVersion)")
|
||||
.font(.title)
|
||||
|
||||
|
||||
if supportedVersion {
|
||||
Text("Your Firmware is up to date")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundStyle(.green)
|
||||
.font(.title2)
|
||||
.padding(.bottom)
|
||||
Text("Current Firmware Version: \(bleManager.connectedVersion)")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.font(.title3)
|
||||
.padding(.bottom)
|
||||
} else {
|
||||
Text("Your Firmware is out of date")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundStyle(.red)
|
||||
.font(.title2)
|
||||
.padding(.bottom)
|
||||
Text("Current Firmware Version: \(bleManager.connectedVersion), Minimium Firmware Version: \(minimumVersion)")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.font(.title3)
|
||||
.padding(.bottom)
|
||||
}
|
||||
Divider()
|
||||
Text("How to update Firmware")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.font(.title2)
|
||||
.padding(.bottom)
|
||||
|
||||
Text("Get the latest stable firmware")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.font(.callout)
|
||||
Link("\(latestStable?.title ?? "unknown".localized)", destination: URL(string: "\(latestStable?.zipURL ?? "https://meshtastic.org")")!)
|
||||
.font(.caption)
|
||||
Link("Release Notes", destination: URL(string: "\(latestStable?.pageURL ?? "https://meshtastic.org")")!)
|
||||
.font(.caption)
|
||||
.padding(.bottom)
|
||||
|
||||
Text("Get the latest alpha firmware")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.font(.callout)
|
||||
Link("\(latestAlpha?.title ?? "unknown".localized)", destination: URL(string: "\(latestAlpha?.zipURL ?? "https://meshtastic.org")")!)
|
||||
.font(.caption)
|
||||
Link("Release Notes", destination: URL(string: "\(latestAlpha?.pageURL ?? "https://meshtastic.org")")!)
|
||||
.font(.caption)
|
||||
.padding(.bottom)
|
||||
|
||||
if currentDevice?.architecture == Meshtastic.Architecture.nrf52840 {
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
Text("Drag & Drop is the reccomended way to update firmware for NRF devices. If your iPhone or iPad is USB-C it will work with your regular USB-C charging cable, for lightning devices you need the Apple Lightning to USB camera adaptor.")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundStyle(.gray)
|
||||
.font(.caption)
|
||||
Link("Drag & Drop Firmware Update Documentation", destination: URL(string: "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/drag-n-drop")!)
|
||||
.font(.caption)
|
||||
.padding(.bottom)
|
||||
VStack {
|
||||
Text("If it is hard to access your device's reset button enter DFU mode here.")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundStyle(.gray)
|
||||
.font(.caption)
|
||||
Button {
|
||||
let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? 0, context: context)
|
||||
if connectedNode != nil {
|
||||
|
||||
if bleManager.sendEnterDfuMode(fromUser: connectedNode!.user!, toUser: node!.user!) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
bleManager.automaticallyReconnect = false
|
||||
bleManager.disconnectPeripheral()
|
||||
}
|
||||
} else {
|
||||
print("Enter DFU Failed")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Enter DFU Mode", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.regular)
|
||||
.padding(5)
|
||||
}
|
||||
Spacer()
|
||||
/// RAK 4631
|
||||
if currentDevice?.hwModel == 9 {
|
||||
Text("nRF OTA Device Firmware Update App")
|
||||
.font(.title3)
|
||||
Text("You can update your Meshtastic device over bluetooth using the Nordic DFU app. This currently works for RAK NRF devices.")
|
||||
Text("You can also update your Meshtastic device over bluetooth using the Nordic DFU app.")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundStyle(.gray)
|
||||
.font(.caption)
|
||||
Link("Get NRF DFU from the App Store", destination: URL(string: "https://apps.apple.com/us/app/nrf-device-firmware-update/id1624454660")!)
|
||||
.font(.callout)
|
||||
.padding(.bottom)
|
||||
} else {
|
||||
Text("OTA Updates are not supported on the this NRF Device.")
|
||||
.font(.title3)
|
||||
|
|
@ -55,7 +148,7 @@ struct Firmware: View {
|
|||
VStack(alignment: .leading) {
|
||||
Text("ESP32 Device Firmware Update")
|
||||
.font(.title3)
|
||||
Text("Currently the reccomended way to update ESP32 devices is using the web flasher from a chrome based browser. It does not work on mobile devices or over BLE.")
|
||||
Text("Currently the reccomended way to update ESP32 devices is using the web flasher on a desktop computer from a chrome based browser. It does not work on mobile devices or over BLE.")
|
||||
.font(.caption)
|
||||
Link("Web Flasher", destination: URL(string: "https://flash.meshtastic.org")!)
|
||||
.font(.callout)
|
||||
|
|
@ -86,170 +179,29 @@ struct Firmware: View {
|
|||
.font(.title3)
|
||||
Text(node?.user?.hwModel ?? "UNSET")
|
||||
.font(.title3)
|
||||
// Text(hwModel.platform().description)
|
||||
// .font(.title3)
|
||||
Text ( currentDevice?.architecture.rawValue ?? "UNKNOWN")
|
||||
.font(.title3)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
VStack(alignment: .leading) {
|
||||
Text("Firmware Releases")
|
||||
.font(.title3)
|
||||
.padding([.leading, .trailing])
|
||||
// List {
|
||||
// Section(header: Text("Stable")) {
|
||||
// ForEach(firmwareReleaseData.releases?.stable ?? [], id: \.id) { fr in
|
||||
// Link(destination: URL(string: fr.zipUrl ?? "")!) {
|
||||
// HStack {
|
||||
// Text(fr.title ?? "Unknown")
|
||||
// .font(.caption)
|
||||
// Spacer()
|
||||
// Image(systemName: "square.and.arrow.down")
|
||||
// .font(.title3)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Section("Alpha") {
|
||||
// ForEach(firmwareReleaseData.releases?.alpha ?? [], id: \.id) { fr in
|
||||
// Link(destination: URL(string: fr.zipUrl ?? "")!) {
|
||||
// HStack {
|
||||
// Text(fr.title ?? "Unknown")
|
||||
// .font(.caption)
|
||||
// Spacer()
|
||||
// Image(systemName: "square.and.arrow.down")
|
||||
// .font(.title3)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Section("Pull Requests") {
|
||||
// ForEach(firmwareReleaseData.pullRequests ?? [], id: \.id) { fr in
|
||||
// Link(destination: URL(string: fr.zipUrl ?? "")!) {
|
||||
// HStack {
|
||||
// Text(fr.title ?? "Unknown")
|
||||
// .font(.caption)
|
||||
// Spacer()
|
||||
// Image(systemName: "square.and.arrow.down")
|
||||
// .font(.title3)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
.padding()
|
||||
.padding(.bottom, 5)
|
||||
.onAppear() {
|
||||
Api().loadDeviceHardwareData { (hw) in
|
||||
for device in hw {
|
||||
print(device)
|
||||
let currentHardware = node?.user?.hwModel ?? "UNSET"
|
||||
let deviceString = device.hwModelSlug.replacingOccurrences(of: "_", with: "")
|
||||
if deviceString == currentHardware {
|
||||
print("Selected: \(device)")
|
||||
currentDevice = device
|
||||
}
|
||||
}
|
||||
}
|
||||
// Api().loadFirmwareReleaseData { (bks) in
|
||||
// //sel = bks
|
||||
// }
|
||||
Api().loadFirmwareReleaseData { (fw) in
|
||||
latestStable = fw.releases.stable.first
|
||||
latestAlpha = fw.releases.alpha.first
|
||||
}
|
||||
}
|
||||
.navigationTitle("Firmware Updates")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//struct FirmwareRelease: Codable {
|
||||
// var releases: Releases? = Releases()
|
||||
// var pullRequests: [PullRequests]? = []
|
||||
// enum CodingKeys: String, CodingKey {
|
||||
// case releases = "Releases"
|
||||
// case pullRequests = "Pull Requests"
|
||||
// }
|
||||
// init(from decoder: Decoder) throws {
|
||||
// let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// releases = try values.decodeIfPresent(Releases.self, forKey: .releases )
|
||||
// pullRequests = try values.decodeIfPresent([PullRequests].self, forKey: .pullRequests )
|
||||
// }
|
||||
// init() {
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//struct Releases: Codable {
|
||||
// var stable: [Stable]? = []
|
||||
// var alpha: [Alpha]? = []
|
||||
// enum CodingKeys: String, CodingKey {
|
||||
// case stable = "Stable"
|
||||
// case alpha = "Alpha"
|
||||
// }
|
||||
// init(from decoder: Decoder) throws {
|
||||
// let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// stable = try values.decodeIfPresent([Stable].self, forKey: .stable )
|
||||
// alpha = try values.decodeIfPresent([Alpha].self, forKey: .alpha )
|
||||
// }
|
||||
// init() {}
|
||||
//}
|
||||
//
|
||||
//struct Alpha: Codable {
|
||||
// var id: String?
|
||||
// var title: String?
|
||||
// var pageUrl: String?
|
||||
// var zipUrl: String?
|
||||
// enum CodingKeys: String, CodingKey {
|
||||
// case id = "id"
|
||||
// case title = "title"
|
||||
// case pageUrl = "page_url"
|
||||
// case zipUrl = "zip_url"
|
||||
// }
|
||||
// init(from decoder: Decoder) throws {
|
||||
// let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// id = try values.decodeIfPresent(String.self, forKey: .id )
|
||||
// title = try values.decodeIfPresent(String.self, forKey: .title )
|
||||
// pageUrl = try values.decodeIfPresent(String.self, forKey: .pageUrl )
|
||||
// zipUrl = try values.decodeIfPresent(String.self, forKey: .zipUrl )
|
||||
// }
|
||||
// init() {}
|
||||
//}
|
||||
//
|
||||
//struct Stable: Codable {
|
||||
// var id: String?
|
||||
// var title: String?
|
||||
// var pageUrl: String?
|
||||
// var zipUrl: String?
|
||||
// enum CodingKeys: String, CodingKey {
|
||||
// case id = "id"
|
||||
// case title = "title"
|
||||
// case pageUrl = "page_url"
|
||||
// case zipUrl = "zip_url"
|
||||
// }
|
||||
// init(from decoder: Decoder) throws {
|
||||
// let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// id = try values.decodeIfPresent(String.self, forKey: .id )
|
||||
// title = try values.decodeIfPresent(String.self, forKey: .title )
|
||||
// pageUrl = try values.decodeIfPresent(String.self, forKey: .pageUrl )
|
||||
// zipUrl = try values.decodeIfPresent(String.self, forKey: .zipUrl )
|
||||
// }
|
||||
// init() {}
|
||||
//}
|
||||
//
|
||||
//struct PullRequests: Codable {
|
||||
// var id: String?
|
||||
// var title: String?
|
||||
// var pageUrl: String?
|
||||
// var zipUrl: String?
|
||||
// enum CodingKeys: String, CodingKey {
|
||||
// case id = "id"
|
||||
// case title = "title"
|
||||
// case pageUrl = "page_url"
|
||||
// case zipUrl = "zip_url"
|
||||
// }
|
||||
// init(from decoder: Decoder) throws {
|
||||
// let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// id = try values.decodeIfPresent(String.self, forKey: .id )
|
||||
// title = try values.decodeIfPresent(String.self, forKey: .title )
|
||||
// pageUrl = try values.decodeIfPresent(String.self, forKey: .pageUrl )
|
||||
// zipUrl = try values.decodeIfPresent(String.self, forKey: .zipUrl )
|
||||
// }
|
||||
// init() {}
|
||||
//}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
//struct DeviceHardware: Codable {
|
||||
// var hwModel: Int
|
||||
// var hwModelSlug: String
|
||||
// var platformioTarget: String
|
||||
// var activelySupported: Bool
|
||||
// var displayName: String
|
||||
//}
|
||||
|
||||
/// Device Hardware API
|
||||
struct DeviceHardware: Codable {
|
||||
let hwModel: Int
|
||||
let hwModelSlug, platformioTarget: String
|
||||
|
|
@ -22,7 +15,6 @@ struct DeviceHardware: Codable {
|
|||
let activelySupported: Bool
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
enum Architecture: String, Codable {
|
||||
case esp32 = "esp32"
|
||||
case esp32C3 = "esp32-c3"
|
||||
|
|
@ -31,9 +23,28 @@ enum Architecture: String, Codable {
|
|||
case rp2040 = "rp2040"
|
||||
}
|
||||
|
||||
/// Firmware Release Lists
|
||||
struct FirmwareReleases: Codable {
|
||||
let releases: Releases
|
||||
let pullRequests: [FirmwareRelease]
|
||||
}
|
||||
struct Releases: Codable {
|
||||
let stable, alpha: [FirmwareRelease]
|
||||
}
|
||||
struct FirmwareRelease: Codable {
|
||||
let id, title: String
|
||||
let pageURL: String
|
||||
let zipURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title
|
||||
case pageURL = "page_url"
|
||||
case zipURL = "zip_url"
|
||||
}
|
||||
}
|
||||
|
||||
class Api : ObservableObject{
|
||||
// @Published var devices = [DeviceHardware]()
|
||||
|
||||
|
||||
func loadDeviceHardwareData(completion:@escaping ([DeviceHardware]) -> ()) {
|
||||
guard let url = URL(string: "https://api.meshtastic.org/resource/deviceHardware") else {
|
||||
print("Invalid url...")
|
||||
|
|
@ -41,23 +52,22 @@ class Api : ObservableObject{
|
|||
}
|
||||
URLSession.shared.dataTask(with: url) { data, response, error in
|
||||
let deviceHardware = try! JSONDecoder().decode([DeviceHardware].self, from: data!)
|
||||
//print(deviceHardware)
|
||||
DispatchQueue.main.async {
|
||||
completion(deviceHardware)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
// func loadFirmwareReleaseData(completion:@escaping ([FirmwareRelease]) -> ()) {
|
||||
// guard let url = URL(string: "https://api.meshtastic.org/github/firmware/list") else {
|
||||
// print("Invalid url...")
|
||||
// return
|
||||
// }
|
||||
// URLSession.shared.dataTask(with: url) { data, response, error in
|
||||
// let deviceHardware = try! JSONDecoder().decode([FirmwareRelease].self, from: data!)
|
||||
// print(deviceHardware)
|
||||
// DispatchQueue.main.async {
|
||||
// completion(deviceHardware)
|
||||
// }
|
||||
// }.resume()
|
||||
// }
|
||||
|
||||
func loadFirmwareReleaseData(completion:@escaping (FirmwareReleases) -> ()) {
|
||||
guard let url = URL(string: "https://api.meshtastic.org/github/firmware/list") else {
|
||||
print("Invalid url...")
|
||||
return
|
||||
}
|
||||
URLSession.shared.dataTask(with: url) { data, response, error in
|
||||
let firmwareReleases = try! JSONDecoder().decode(FirmwareReleases.self, from: data!)
|
||||
DispatchQueue.main.async {
|
||||
completion(firmwareReleases)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,289 +1,289 @@
|
|||
////
|
||||
//// Routes.swift
|
||||
//// Meshtastic
|
||||
////
|
||||
//// Created by Garth Vander Houwen on 11/21/23.
|
||||
////
|
||||
//
|
||||
// Routes.swift
|
||||
// Meshtastic
|
||||
//import SwiftUI
|
||||
//import CoreData
|
||||
//import MapKit
|
||||
//import CoreLocation
|
||||
//import CoreMotion
|
||||
//
|
||||
// Created by Garth Vander Houwen on 11/21/23.
|
||||
//@available(iOS 17.0, macOS 14.0, *)
|
||||
//struct RouteRecorder: View {
|
||||
//
|
||||
// @ObservedObject var locationsHandler: LocationsHandler = LocationsHandler.shared
|
||||
// @Environment(\.managedObjectContext) var context
|
||||
// @State private var position: MapCameraPosition = .userLocation(followsHeading: true, fallback: .automatic)
|
||||
// //@State var mapStyle: MapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: .all, showsTraffic: true)
|
||||
// @State var mapStyle: MapStyle = MapStyle.standard(elevation: .realistic)
|
||||
// @State var isShowingDetails = false
|
||||
// @Namespace var namespace
|
||||
// @Namespace var routerecorderscope
|
||||
// @State var recording: RouteEntity?
|
||||
// @State var color: Color = .blue
|
||||
//
|
||||
// var body: some View {
|
||||
// VStack {
|
||||
// ZStack {
|
||||
// Map(position: $position, scope: routerecorderscope) {
|
||||
// UserAnnotation()
|
||||
// /// Route Lines
|
||||
// let lineCoords = locationsHandler.locationsArray.compactMap({(position) -> CLLocationCoordinate2D in
|
||||
// return position.coordinate
|
||||
// })
|
||||
//
|
||||
// let gradient = LinearGradient(
|
||||
// colors: [color],
|
||||
// startPoint: .leading, endPoint: .trailing
|
||||
// )
|
||||
// let dashed = StrokeStyle(
|
||||
// lineWidth: 3,
|
||||
// lineCap: .round, lineJoin: .round, dash: [10, 10]
|
||||
// )
|
||||
// MapPolyline(coordinates: lineCoords)
|
||||
// .stroke(gradient, style: dashed)
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import MapKit
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
|
||||
@available(iOS 17.0, macOS 14.0, *)
|
||||
struct RouteRecorder: View {
|
||||
|
||||
@ObservedObject var locationsHandler: LocationsHandler = LocationsHandler.shared
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@State private var position: MapCameraPosition = .userLocation(followsHeading: true, fallback: .automatic)
|
||||
//@State var mapStyle: MapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: .all, showsTraffic: true)
|
||||
@State var mapStyle: MapStyle = MapStyle.standard(elevation: .realistic)
|
||||
@State var isShowingDetails = false
|
||||
@Namespace var namespace
|
||||
@Namespace var routerecorderscope
|
||||
@State var recording: RouteEntity?
|
||||
@State var color: Color = .blue
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ZStack {
|
||||
Map(position: $position, scope: routerecorderscope) {
|
||||
UserAnnotation()
|
||||
/// Route Lines
|
||||
let lineCoords = locationsHandler.locationsArray.compactMap({(position) -> CLLocationCoordinate2D in
|
||||
return position.coordinate
|
||||
})
|
||||
|
||||
let gradient = LinearGradient(
|
||||
colors: [color],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
let dashed = StrokeStyle(
|
||||
lineWidth: 3,
|
||||
lineCap: .round, lineJoin: .round, dash: [10, 10]
|
||||
)
|
||||
MapPolyline(coordinates: lineCoords)
|
||||
.stroke(gradient, style: dashed)
|
||||
|
||||
}
|
||||
.mapStyle(mapStyle)
|
||||
}
|
||||
.ignoresSafeArea(.all, edges: [.top, .leading, .trailing])
|
||||
.mapScope(routerecorderscope)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
ZStack {
|
||||
VStack {
|
||||
HStack(spacing: 10) {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
isShowingDetails = true
|
||||
} label: {
|
||||
Image(systemName: locationsHandler.isRecording ? "record.circle.fill" : "record.circle")
|
||||
.font(.system(size: 72))
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.foregroundColor(.red)
|
||||
.buttonBorderShape(.circle)
|
||||
.matchedGeometryEffect(id: "Details Button", in: namespace)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.sheet(isPresented: $isShowingDetails) {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
if locationsHandler.isRecording {
|
||||
HStack (alignment: .center) {
|
||||
Image(systemName: "record.circle.fill")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.font(.title)
|
||||
.foregroundColor(.red)
|
||||
Text("Recording route")
|
||||
.font(.title)
|
||||
Spacer()
|
||||
Text("\(locationsHandler.count)")
|
||||
.foregroundColor(.red)
|
||||
.font(.title2)
|
||||
}
|
||||
.padding()
|
||||
} else if locationsHandler.isRecordingPaused {
|
||||
HStack (alignment: .center) {
|
||||
|
||||
Image(systemName: "playpause")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.font(.title3)
|
||||
.foregroundColor(.red)
|
||||
Text("Route recording paused")
|
||||
.font(.title)
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
if locationsHandler.isRecording || locationsHandler.isRecordingPaused {
|
||||
Divider()
|
||||
HStack {
|
||||
VStack {
|
||||
Text(locationsHandler.recordingStarted ?? Date(), style: .timer)
|
||||
.font(.title)
|
||||
.fixedSize()
|
||||
Text("Time")
|
||||
.font(.callout)
|
||||
.fixedSize()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
Divider()
|
||||
VStack {
|
||||
let distance = Measurement(value: locationsHandler.distanceTraveled, unit: UnitLength.meters)
|
||||
Text("\(distance.formatted())")
|
||||
.font(.title)
|
||||
.fixedSize()
|
||||
Text("Distance")
|
||||
.font(.callout)
|
||||
.fixedSize()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
Divider()
|
||||
VStack {
|
||||
let gain = Measurement(value: locationsHandler.elevationGain, unit: UnitLength.meters)
|
||||
Text(gain.formatted())
|
||||
.font(.title)
|
||||
Text("Elev. Gain")
|
||||
.font(.callout)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxHeight: 90)
|
||||
}
|
||||
Divider()
|
||||
VStack(alignment: .leading) {
|
||||
List {
|
||||
GPSStatus(largeFont: .body, smallFont: .callout)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
HStack {
|
||||
Spacer()
|
||||
if !locationsHandler.isRecording && !locationsHandler.isRecordingPaused {
|
||||
/// We are not recording or paused, show start recording button
|
||||
Button {
|
||||
locationsHandler.isRecording = true
|
||||
locationsHandler.count = 0
|
||||
locationsHandler.distanceTraveled = 0.0
|
||||
locationsHandler.elevationGain = 0.0
|
||||
locationsHandler.locationsArray.removeAll()
|
||||
locationsHandler.recordingStarted = Date()
|
||||
let newRoute = RouteEntity(context: context)
|
||||
newRoute.name = String("Route Recording")
|
||||
newRoute.id = Int32.random(in: Int32(Int8.max) ... Int32.max)
|
||||
newRoute.color = Int64(UIColor.random.hex)
|
||||
newRoute.date = Date()
|
||||
newRoute.enabled = false
|
||||
color = Color(UIColor(hex: UInt32(newRoute.color)))
|
||||
self.recording = newRoute
|
||||
do {
|
||||
try context.save()
|
||||
print("💾 Saved a new route")
|
||||
} catch {
|
||||
context.rollback()
|
||||
let nsError = error as NSError
|
||||
print("💥 Error Saving RouteEntity from the Route Recorder \(nsError)")
|
||||
}
|
||||
} label: {
|
||||
Label("start", systemImage: "play")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding(.bottom)
|
||||
|
||||
} else if locationsHandler.isRecording {
|
||||
/// We are recording show pause button
|
||||
Button {
|
||||
locationsHandler.isRecording = false
|
||||
locationsHandler.isRecordingPaused = true
|
||||
} label: {
|
||||
Label("pause", systemImage: "pause")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding(.bottom)
|
||||
} else if locationsHandler.isRecordingPaused {
|
||||
/// We are paused show resume button
|
||||
Button {
|
||||
locationsHandler.isRecording = true
|
||||
locationsHandler.isRecordingPaused = false
|
||||
} label: {
|
||||
Label("resume", systemImage: "playpause")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
if locationsHandler.isRecording || locationsHandler.isRecordingPaused {
|
||||
/// We are recording or paused, show finish button
|
||||
Button {
|
||||
locationsHandler.isRecording = false
|
||||
locationsHandler.isRecordingPaused = false
|
||||
locationsHandler.distanceTraveled = 0.0
|
||||
locationsHandler.elevationGain = 0.0
|
||||
locationsHandler.locationsArray.removeAll()
|
||||
locationsHandler.recordingStarted = nil
|
||||
if let rec = recording {
|
||||
rec.enabled = true
|
||||
context.refresh(rec, mergeChanges:true)
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
print("💾 Saved a route finish")
|
||||
} catch {
|
||||
context.rollback()
|
||||
let nsError = error as NSError
|
||||
print("💥 Error Saving RouteEntity from the Route Recorder \(nsError)")
|
||||
}
|
||||
} label: {
|
||||
Label("finish", systemImage: "flag.checkered")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding(.bottom)
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Button(role: .cancel) {
|
||||
isShowingDetails = false
|
||||
} label: {
|
||||
Label("close", systemImage: "xmark")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding(.bottom)
|
||||
#endif
|
||||
Spacer()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.fraction(0.30), .fraction(0.65)])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.interactiveDismissDisabled(false)
|
||||
.onChange(of: locationsHandler.locationsArray.last) { newLoc in
|
||||
if locationsHandler.isRecording {
|
||||
if let loc = newLoc {
|
||||
if recording != nil {
|
||||
let locationEntity = LocationEntity(context: context)
|
||||
locationEntity.routeLocation = recording
|
||||
locationEntity.id = Int32(locationsHandler.count)
|
||||
locationEntity.altitude = Int32(loc.altitude)
|
||||
locationEntity.heading = Int32(loc.course)
|
||||
locationEntity.speed = Int32(loc.speed)
|
||||
locationEntity.latitudeI = Int32(loc.coordinate.latitude * 1e7)
|
||||
locationEntity.longitudeI = Int32(loc.coordinate.longitude * 1e7)
|
||||
do {
|
||||
try context.save()
|
||||
print("💾 Saved a new route location")
|
||||
//print("💾 Updated Canned Messages Messages For: \(fetchedNode[0].num)")
|
||||
} catch {
|
||||
context.rollback()
|
||||
let nsError = error as NSError
|
||||
print("💥 Error Saving LocationEntity from the Route Recorder \(nsError)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// }
|
||||
// .mapStyle(mapStyle)
|
||||
// }
|
||||
// .mapScope(routerecorderscope)
|
||||
// .safeAreaInset(edge: .bottom) {
|
||||
// ZStack {
|
||||
// VStack {
|
||||
// HStack(spacing: 10) {
|
||||
// Spacer()
|
||||
//
|
||||
// Button {
|
||||
// isShowingDetails = true
|
||||
// } label: {
|
||||
// Image(systemName: locationsHandler.isRecording ? "record.circle.fill" : "record.circle")
|
||||
// .font(.system(size: 72))
|
||||
// .symbolRenderingMode(.multicolor)
|
||||
// .foregroundColor(.red)
|
||||
// }
|
||||
// .buttonStyle(.bordered)
|
||||
// .foregroundColor(.red)
|
||||
// .buttonBorderShape(.circle)
|
||||
// .matchedGeometryEffect(id: "Details Button", in: namespace)
|
||||
//
|
||||
// Spacer()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// .padding()
|
||||
// }
|
||||
// .sheet(isPresented: $isShowingDetails) {
|
||||
// NavigationStack {
|
||||
// VStack {
|
||||
// if locationsHandler.isRecording {
|
||||
// HStack (alignment: .center) {
|
||||
// Image(systemName: "record.circle.fill")
|
||||
// .symbolRenderingMode(.multicolor)
|
||||
// .font(.title)
|
||||
// .foregroundColor(.red)
|
||||
// Text("Recording route")
|
||||
// .font(.title)
|
||||
// Spacer()
|
||||
// Text("\(locationsHandler.count)")
|
||||
// .foregroundColor(.red)
|
||||
// .font(.title2)
|
||||
// }
|
||||
// .padding()
|
||||
// } else if locationsHandler.isRecordingPaused {
|
||||
// HStack (alignment: .center) {
|
||||
//
|
||||
// Image(systemName: "playpause")
|
||||
// .symbolRenderingMode(.multicolor)
|
||||
// .font(.title3)
|
||||
// .foregroundColor(.red)
|
||||
// Text("Route recording paused")
|
||||
// .font(.title)
|
||||
// }
|
||||
// .padding(.top)
|
||||
// }
|
||||
//
|
||||
// if locationsHandler.isRecording || locationsHandler.isRecordingPaused {
|
||||
// Divider()
|
||||
// HStack {
|
||||
// VStack {
|
||||
// Text(locationsHandler.recordingStarted ?? Date(), style: .timer)
|
||||
// .font(.title)
|
||||
// .fixedSize()
|
||||
// Text("Time")
|
||||
// .font(.callout)
|
||||
// .fixedSize()
|
||||
// }
|
||||
// .padding(.horizontal)
|
||||
// Divider()
|
||||
// VStack {
|
||||
// let distance = Measurement(value: locationsHandler.distanceTraveled, unit: UnitLength.meters)
|
||||
// Text("\(distance.formatted())")
|
||||
// .font(.title)
|
||||
// .fixedSize()
|
||||
// Text("Distance")
|
||||
// .font(.callout)
|
||||
// .fixedSize()
|
||||
// }
|
||||
// .padding(.horizontal)
|
||||
// Divider()
|
||||
// VStack {
|
||||
// let gain = Measurement(value: locationsHandler.elevationGain, unit: UnitLength.meters)
|
||||
// Text(gain.formatted())
|
||||
// .font(.title)
|
||||
// Text("Elev. Gain")
|
||||
// .font(.callout)
|
||||
// }
|
||||
// .padding(.horizontal)
|
||||
// }
|
||||
// .frame(maxHeight: 90)
|
||||
// }
|
||||
// Divider()
|
||||
// VStack(alignment: .leading) {
|
||||
// List {
|
||||
// GPSStatus(largeFont: .body, smallFont: .callout)
|
||||
// }
|
||||
// .listStyle(.plain)
|
||||
// HStack {
|
||||
// Spacer()
|
||||
// if !locationsHandler.isRecording && !locationsHandler.isRecordingPaused {
|
||||
// /// We are not recording or paused, show start recording button
|
||||
// Button {
|
||||
// locationsHandler.isRecording = true
|
||||
// locationsHandler.count = 0
|
||||
// locationsHandler.distanceTraveled = 0.0
|
||||
// locationsHandler.elevationGain = 0.0
|
||||
// locationsHandler.locationsArray.removeAll()
|
||||
// locationsHandler.recordingStarted = Date()
|
||||
// let newRoute = RouteEntity(context: context)
|
||||
// newRoute.name = String("Route Recording")
|
||||
// newRoute.id = Int32.random(in: Int32(Int8.max) ... Int32.max)
|
||||
// newRoute.color = Int64(UIColor.random.hex)
|
||||
// newRoute.date = Date()
|
||||
// newRoute.enabled = false
|
||||
// color = Color(UIColor(hex: UInt32(newRoute.color)))
|
||||
// self.recording = newRoute
|
||||
// do {
|
||||
// try context.save()
|
||||
// print("💾 Saved a new route")
|
||||
// } catch {
|
||||
// context.rollback()
|
||||
// let nsError = error as NSError
|
||||
// print("💥 Error Saving RouteEntity from the Route Recorder \(nsError)")
|
||||
// }
|
||||
// } label: {
|
||||
// Label("start", systemImage: "play")
|
||||
// }
|
||||
// .buttonStyle(.bordered)
|
||||
// .buttonBorderShape(.capsule)
|
||||
// .controlSize(.large)
|
||||
// .padding(.bottom)
|
||||
//
|
||||
// } else if locationsHandler.isRecording {
|
||||
// /// We are recording show pause button
|
||||
// Button {
|
||||
// locationsHandler.isRecording = false
|
||||
// locationsHandler.isRecordingPaused = true
|
||||
// } label: {
|
||||
// Label("pause", systemImage: "pause")
|
||||
// }
|
||||
// .buttonStyle(.bordered)
|
||||
// .buttonBorderShape(.capsule)
|
||||
// .controlSize(.large)
|
||||
// .padding(.bottom)
|
||||
// } else if locationsHandler.isRecordingPaused {
|
||||
// /// We are paused show resume button
|
||||
// Button {
|
||||
// locationsHandler.isRecording = true
|
||||
// locationsHandler.isRecordingPaused = false
|
||||
// } label: {
|
||||
// Label("resume", systemImage: "playpause")
|
||||
// }
|
||||
// .buttonStyle(.bordered)
|
||||
// .buttonBorderShape(.capsule)
|
||||
// .controlSize(.large)
|
||||
// .padding(.bottom)
|
||||
// }
|
||||
//
|
||||
// if locationsHandler.isRecording || locationsHandler.isRecordingPaused {
|
||||
// /// We are recording or paused, show finish button
|
||||
// Button {
|
||||
// locationsHandler.isRecording = false
|
||||
// locationsHandler.isRecordingPaused = false
|
||||
// locationsHandler.distanceTraveled = 0.0
|
||||
// locationsHandler.elevationGain = 0.0
|
||||
// locationsHandler.locationsArray.removeAll()
|
||||
// locationsHandler.recordingStarted = nil
|
||||
// if let rec = recording {
|
||||
// rec.enabled = true
|
||||
// context.refresh(rec, mergeChanges:true)
|
||||
// }
|
||||
//
|
||||
// do {
|
||||
// try context.save()
|
||||
// print("💾 Saved a route finish")
|
||||
// } catch {
|
||||
// context.rollback()
|
||||
// let nsError = error as NSError
|
||||
// print("💥 Error Saving RouteEntity from the Route Recorder \(nsError)")
|
||||
// }
|
||||
// } label: {
|
||||
// Label("finish", systemImage: "flag.checkered")
|
||||
// }
|
||||
// .buttonStyle(.bordered)
|
||||
// .buttonBorderShape(.capsule)
|
||||
// .controlSize(.large)
|
||||
// .padding(.bottom)
|
||||
// }
|
||||
//#if targetEnvironment(macCatalyst)
|
||||
// Button(role: .cancel) {
|
||||
// isShowingDetails = false
|
||||
// } label: {
|
||||
// Label("close", systemImage: "xmark")
|
||||
// }
|
||||
// .buttonStyle(.bordered)
|
||||
// .buttonBorderShape(.capsule)
|
||||
// .controlSize(.large)
|
||||
// .padding(.bottom)
|
||||
//#endif
|
||||
// Spacer()
|
||||
// }
|
||||
//
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// .presentationDetents([.fraction(0.30), .fraction(0.65)])
|
||||
// .presentationDragIndicator(.hidden)
|
||||
// .interactiveDismissDisabled(false)
|
||||
// .onChange(of: locationsHandler.locationsArray.last) { newLoc in
|
||||
// if locationsHandler.isRecording {
|
||||
// if let loc = newLoc {
|
||||
// if recording != nil {
|
||||
// let locationEntity = LocationEntity(context: context)
|
||||
// locationEntity.routeLocation = recording
|
||||
// locationEntity.id = Int32(locationsHandler.count)
|
||||
// locationEntity.altitude = Int32(loc.altitude)
|
||||
// locationEntity.heading = Int32(loc.course)
|
||||
// locationEntity.speed = Int32(loc.speed)
|
||||
// locationEntity.latitudeI = Int32(loc.coordinate.latitude * 1e7)
|
||||
// locationEntity.longitudeI = Int32(loc.coordinate.longitude * 1e7)
|
||||
// do {
|
||||
// try context.save()
|
||||
// print("💾 Saved a new route location")
|
||||
// //print("💾 Updated Canned Messages Messages For: \(fetchedNode[0].num)")
|
||||
// } catch {
|
||||
// context.rollback()
|
||||
// let nsError = error as NSError
|
||||
// print("💥 Error Saving LocationEntity from the Route Recorder \(nsError)")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// .ignoresSafeArea(.all, edges: [.top, .leading, .trailing])
|
||||
// }
|
||||
//}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ struct Routes: View {
|
|||
@State private var selectedRoute: RouteEntity?
|
||||
@State private var importing = false
|
||||
@State private var isShowingBadFileAlert = false
|
||||
@State var isExporting = false
|
||||
@State var exportString = ""
|
||||
|
||||
@FetchRequest(sortDescriptors: [NSSortDescriptor(key: "enabled", ascending: false), NSSortDescriptor(key: "name", ascending: true), NSSortDescriptor(key: "date", ascending: false)], animation: .default)
|
||||
|
||||
|
|
@ -33,7 +35,7 @@ struct Routes: View {
|
|||
.padding()
|
||||
|
||||
.alert(isPresented: $isShowingBadFileAlert) {
|
||||
Alert(title: Text("Not a valid route file"), message: Text("Your route file must have both Latitude and Longitude."), dismissButton: .default(Text("OK")))
|
||||
Alert(title: Text("Not a valid route file"), message: Text("Your route file must have both Latitude and Longitude columns and headers."), dismissButton: .default(Text("OK")))
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $importing,
|
||||
|
|
@ -187,8 +189,36 @@ struct Routes: View {
|
|||
.stroke(Color(UIColor(hex: UInt32(selectedRoute?.color ?? 0))), style: dashed)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.safeAreaInset(edge: .bottom, alignment: UIDevice.current.userInterfaceIdiom == .phone ? .leading : .trailing) {
|
||||
Button {
|
||||
exportString = routeToCsvFile(locations: selectedRoute!.locations!.array as? [LocationEntity] ?? [])
|
||||
isExporting = true
|
||||
} label: {
|
||||
Label("save", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding(.bottom)
|
||||
.padding(.leading)
|
||||
}
|
||||
|
||||
}
|
||||
}.navigationTitle(" \(selectedRoute?.name ?? "Unknown Route") \(selectedRoute?.locations?.count ?? 0) points")
|
||||
}
|
||||
.fileExporter(
|
||||
isPresented: $isExporting,
|
||||
document: CsvDocument(emptyCsv: exportString),
|
||||
contentType: .commaSeparatedText,
|
||||
defaultFilename: String("\(selectedRoute?.name ?? "Route") Log"),
|
||||
onCompletion: { result in
|
||||
if case .success = result {
|
||||
print("Route log download succeeded.")
|
||||
self.isExporting = false
|
||||
} else {
|
||||
print("Route log download failed: \(result).")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ struct Settings: View {
|
|||
Text("routes")
|
||||
}
|
||||
.tag(SettingsSidebar.routes)
|
||||
|
||||
// NavigationLink {
|
||||
// RouteRecorder()
|
||||
// } label: {
|
||||
|
|
@ -299,22 +298,26 @@ struct Settings: View {
|
|||
}
|
||||
.tag(SettingsSidebar.adminMessageLog)
|
||||
}
|
||||
// Section(header: Text("Firmware")) {
|
||||
// NavigationLink {
|
||||
// Firmware(node: nodes.first(where: { $0.num == preferredNodeNum }))
|
||||
// } label: {
|
||||
// Image(systemName: "arrow.up.arrow.down.square")
|
||||
// .symbolRenderingMode(.hierarchical)
|
||||
// Text("Firmware Updates")
|
||||
// }
|
||||
// .tag(SettingsSidebar.about)
|
||||
// .disabled(selectedNode > 0 && selectedNode != preferredNodeNum)
|
||||
// }
|
||||
Section(header: Text("Firmware")) {
|
||||
NavigationLink {
|
||||
Firmware(node: nodes.first(where: { $0.num == preferredNodeNum }))
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.arrow.down.square")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text("Firmware Updates")
|
||||
}
|
||||
.tag(SettingsSidebar.about)
|
||||
.disabled(selectedNode > 0 && selectedNode != preferredNodeNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.preferredNodeNum = UserDefaults.preferredPeripheralNum
|
||||
if selectedNode == 0 {
|
||||
if nodes.count > 1 {
|
||||
if selectedNode == 0 {
|
||||
self.selectedNode = Int(bleManager.connectedPeripheral != nil ? UserDefaults.preferredPeripheralNum : 0)
|
||||
}
|
||||
} else {
|
||||
self.selectedNode = Int(bleManager.connectedPeripheral != nil ? UserDefaults.preferredPeripheralNum : 0)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue