Merge pull request #461 from meshtastic/2.2.19_Working_Changes

2.2.19 Working Changes
This commit is contained in:
Garth Vander Houwen 2024-01-16 19:11:16 -08:00 committed by GitHub
commit d63187ecd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 621 additions and 537 deletions

View file

@ -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 = "";

View file

@ -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
}

View file

@ -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
}

View file

@ -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()

View file

@ -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

View file

@ -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(

View file

@ -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")) {

View file

@ -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
}

View file

@ -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)

View file

@ -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() {}
//}

View file

@ -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()
}
}

View file

@ -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])
// }
//}

View file

@ -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).")
}
}
)
}
}

View file

@ -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)
}
}