Core data checkpoint 1, all pages load

This commit is contained in:
Garth Vander Houwen 2021-12-15 23:53:45 -08:00
parent 4a08431766
commit fd5b9eb1c3
14 changed files with 819 additions and 544 deletions

View file

@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */; };
DD23A51326FEF5D500D9B90C /* MessageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A51226FEF5D500D9B90C /* MessageData.swift */; };
DD2E652427679E4000E45FC5 /* NodeInfoEntityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2E652327679E4000E45FC5 /* NodeInfoEntityRow.swift */; };
DD2E65262767A01F00E45FC5 /* NodeInfoEntityDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2E65252767A01F00E45FC5 /* NodeInfoEntityDetail.swift */; };
DD47E3CC26F0E51D00029299 /* NodeDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3CB26F0E51D00029299 /* NodeDetail.swift */; };
DD47E3CE26F103C600029299 /* NodeList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3CD26F103C600029299 /* NodeList.swift */; };
DD47E3D026F1073F00029299 /* NodeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3CF26F1073F00029299 /* NodeRow.swift */; };
@ -19,13 +21,13 @@
DD47E3DF26F39D9F00029299 /* MyInfoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3DE26F39D9F00029299 /* MyInfoModel.swift */; };
DD4A911E2708C65400501B7E /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4A911D2708C65400501B7E /* AppSettings.swift */; };
DD4A91202708C66600501B7E /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4A911F2708C66600501B7E /* Configuration.swift */; };
DD5394FC276993AD00AD86B1 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = DD5394FB276993AD00AD86B1 /* SwiftProtobuf */; };
DD8169F9271F1A6100F4AB02 /* MeshLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8169F8271F1A6100F4AB02 /* MeshLogger.swift */; };
DD8169FB271F1F3A00F4AB02 /* MeshLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8169FA271F1F3A00F4AB02 /* MeshLog.swift */; };
DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8169FE272476C700F4AB02 /* LogDocument.swift */; };
DD836AE726F6B38600ABCC23 /* Connect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD836AE626F6B38600ABCC23 /* Connect.swift */; };
DD836AED26F858F900ABCC23 /* MeshData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD836AEC26F858F900ABCC23 /* MeshData.swift */; };
DD836AEF26F85D8D00ABCC23 /* NodeInfoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD836AEE26F85D8D00ABCC23 /* NodeInfoModel.swift */; };
DD8EDE9426F97A2B00A5A10B /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = DD8EDE9326F97A2B00A5A10B /* SwiftProtobuf */; };
DD90860C26F684AF00DC5189 /* BatteryIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD90860B26F684AF00DC5189 /* BatteryIcon.swift */; };
DD90860E26F69BAE00DC5189 /* NodeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD90860D26F69BAE00DC5189 /* NodeMap.swift */; };
DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */; };
@ -73,6 +75,8 @@
/* Begin PBXFileReference section */
DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralModel.swift; sourceTree = "<group>"; };
DD23A51226FEF5D500D9B90C /* MessageData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageData.swift; sourceTree = "<group>"; };
DD2E652327679E4000E45FC5 /* NodeInfoEntityRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityRow.swift; sourceTree = "<group>"; };
DD2E65252767A01F00E45FC5 /* NodeInfoEntityDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityDetail.swift; sourceTree = "<group>"; };
DD47E3CB26F0E51D00029299 /* NodeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeDetail.swift; sourceTree = "<group>"; };
DD47E3CD26F103C600029299 /* NodeList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeList.swift; sourceTree = "<group>"; };
DD47E3CF26F1073F00029299 /* NodeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeRow.swift; sourceTree = "<group>"; };
@ -128,7 +132,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
DD8EDE9426F97A2B00A5A10B /* SwiftProtobuf in Frameworks */,
DD5394FC276993AD00AD86B1 /* SwiftProtobuf in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -156,6 +160,8 @@
DD47E3CD26F103C600029299 /* NodeList.swift */,
DD47E3CF26F1073F00029299 /* NodeRow.swift */,
DD90860D26F69BAE00DC5189 /* NodeMap.swift */,
DD2E652327679E4000E45FC5 /* NodeInfoEntityRow.swift */,
DD2E65252767A01F00E45FC5 /* NodeInfoEntityDetail.swift */,
);
path = Nodes;
sourceTree = "<group>";
@ -360,7 +366,7 @@
);
name = MeshtasticClient;
packageProductDependencies = (
DD8EDE9326F97A2B00A5A10B /* SwiftProtobuf */,
DD5394FB276993AD00AD86B1 /* SwiftProtobuf */,
);
productName = MeshtasticClient;
productReference = DDC2E15426CE248E0042C5E4 /* MeshtasticClient.app */;
@ -434,7 +440,7 @@
);
mainGroup = DDC2E14B26CE248E0042C5E4;
packageReferences = (
DDAF8C5926ED08D30058C060 /* XCRemoteSwiftPackageReference "swift-protobuf" */,
DD5394FA276993AD00AD86B1 /* XCRemoteSwiftPackageReference "swift-protobuf" */,
);
productRefGroup = DDC2E15526CE248E0042C5E4 /* Products */;
projectDirPath = "";
@ -507,6 +513,7 @@
DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */,
DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */,
DDC4D568275499A500A4208E /* Persistence.swift in Sources */,
DD2E652427679E4000E45FC5 /* NodeInfoEntityRow.swift in Sources */,
DD90860E26F69BAE00DC5189 /* NodeMap.swift in Sources */,
DD47E3DB26F3901B00029299 /* Channels.swift in Sources */,
DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */,
@ -531,6 +538,7 @@
DDAF8C6326ED0A230058C060 /* admin.pb.swift in Sources */,
DDAF8C5826ED07FD0058C060 /* mesh.pb.swift in Sources */,
DD8169FB271F1F3A00F4AB02 /* MeshLog.swift in Sources */,
DD2E65262767A01F00E45FC5 /* NodeInfoEntityDetail.swift in Sources */,
DD47E3D926F3093800029299 /* MessageBubble.swift in Sources */,
DD8169F9271F1A6100F4AB02 /* MeshLogger.swift in Sources */,
DDAF8C6726ED0C8C0058C060 /* remote_hardware.pb.swift in Sources */,
@ -870,20 +878,20 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
DDAF8C5926ED08D30058C060 /* XCRemoteSwiftPackageReference "swift-protobuf" */ = {
DD5394FA276993AD00AD86B1 /* XCRemoteSwiftPackageReference "swift-protobuf" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-protobuf.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.17.0;
minimumVersion = 1.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
DD8EDE9326F97A2B00A5A10B /* SwiftProtobuf */ = {
DD5394FB276993AD00AD86B1 /* SwiftProtobuf */ = {
isa = XCSwiftPackageProductDependency;
package = DDAF8C5926ED08D30058C060 /* XCRemoteSwiftPackageReference "swift-protobuf" */;
package = DD5394FA276993AD00AD86B1 /* XCRemoteSwiftPackageReference "swift-protobuf" */;
productName = SwiftProtobuf;
};
/* End XCSwiftPackageProductDependency section */

View file

@ -65,18 +65,82 @@
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "C16C366D-F4CF-4FE2-A87D-3A8BBA3A2840"
uuid = "2CAE774E-7819-413A-91D3-559FCC82C1FB"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "MeshtasticClient/Helpers/BLEManager.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "555"
endingLineNumber = "555"
landmarkName = "peripheral(_:didUpdateValueFor:error:)"
startingLineNumber = "839"
endingLineNumber = "839"
landmarkName = "sendMessage(message:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "BE4B1DBA-2314-49AB-B64E-4DB2EBF1A1C7"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "MeshtasticClient/Helpers/BLEManager.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "862"
endingLineNumber = "862"
landmarkName = "sendMessage(message:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "2CF5DF91-B5B3-4CAF-8A13-C6B6815835CE"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "MeshtasticClient/Views/Helpers/MessageBubble.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "38"
endingLineNumber = "38"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "03628B8D-EBF0-453E-884C-1161447647CD"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "MeshtasticClient/Views/Messages/Messages.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "54"
endingLineNumber = "54"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "2F7DD194-C7B0-472F-8346-9128AE44D6AD"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "MeshtasticClient/Views/Messages/Messages.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "56"
endingLineNumber = "56"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>

View file

@ -19,12 +19,13 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
}
var context: NSManagedObjectContext?
private var centralManager: CBCentralManager!
@Published var peripherals = [Peripheral]()
@Published var connectedPeripheral: Peripheral!
@Published var connectedNode: NodeInfoModel!
@Published var connectedNode: NodeInfoEntity!
@Published var lastConnectedPeripheral: String
@Published var lastConnectionError: String
@ -49,25 +50,16 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
private var meshLoggingEnabled: Bool = false
let meshLog = documentsFolder.appendingPathComponent("meshlog.txt")
//Eventually Delete
@Published var meshData: MeshData
//@Published var messageData: MessageData
// MARK: init BLEManager
override init() {
self.meshLoggingEnabled = UserDefaults.standard.object(forKey: "meshActivityLog") as? Bool ?? false
self.meshData = MeshData()
//self.messageData = MessageData()
self.lastConnectedPeripheral = ""
self.lastConnectionError = ""
super.init()
//let bleQueue: DispatchQueue = DispatchQueue(label: "CentralManager")
centralManager = CBCentralManager(delegate: self, queue: nil)
meshData.load()
//messageData.load()
}
// MARK: Bluetooth enabled/disabled for the app
@ -82,7 +74,6 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
}
}
// MARK: Scanning for BLE Devices
// Scan for nearby BLE devices using the Meshtastic BLE service ID
func startScanning() {
@ -113,8 +104,8 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
/// - timer: The time that fired the event
///
@objc func timeoutTimerFired(timer: Timer) {
guard let context = timer.userInfo as? [String: String] else { return }
let name: String = context["name", default: "Unknown"]
guard let timerContext = timer.userInfo as? [String: String] else { return }
let name: String = timerContext["name", default: "Unknown"]
self.timeoutTimerCount += 1
@ -124,7 +115,6 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
self.centralManager?.cancelPeripheralConnection(connectedPeripheral.peripheral)
}
connectedNode = nil
connectedPeripheral = nil
self.lastConnectionError = "BLE Connecting Timeout after making \(timeoutTimerCount) attempts to connect to \(name)."
@ -177,12 +167,12 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
peripheralName = name
}
var newPeripheral = Peripheral(id: peripheral.identifier.uuidString, name: peripheralName, rssi: RSSI.intValue, subscribed: false, peripheral: peripheral, myInfo: nil)
let newPeripheral = Peripheral(id: peripheral.identifier.uuidString, name: peripheralName, firmwareVersion: "Unknown", rssi: RSSI.intValue, subscribed: false, peripheral: peripheral, myInfo: nil)
let peripheralIndex = peripherals.firstIndex(where: { $0.id == newPeripheral.id })
if peripheralIndex != nil && newPeripheral.peripheral.state != CBPeripheralState.connected {
newPeripheral.myInfo = peripherals.first(where: { $0.id == newPeripheral.id })?.myInfo
//newPeripheral.myInfo = peripherals.first(where: { $0.id == newPeripheral.id })?.myInfo
peripherals[peripheralIndex!] = newPeripheral
peripherals.remove(at: peripheralIndex!)
peripherals.append(newPeripheral)
@ -211,9 +201,23 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
// Map the peripheral to the connectedNode and connectedPeripheral ObservedObjects
connectedPeripheral = peripherals.filter({ $0.peripheral.identifier == peripheral.identifier }).first
connectedPeripheral.peripheral.delegate = self
let deviceName = peripheral.name ?? ""
let peripheralLast4: String = String(deviceName.suffix(4))
connectedNode = self.meshData.nodes.first(where: { $0.user.id.contains(peripheralLast4) })
let fetchNodeRequest:NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "NodeInfoEntity")
fetchNodeRequest.predicate = NSPredicate(format: "bleName MATCHES %@", String(peripheral.name ?? "???"))
do {
let fetchedNode = try context?.fetch(fetchNodeRequest) as! [NodeInfoEntity]
if fetchedNode.count == 1 {
connectedPeripheral.name = fetchedNode[0].user!.longName!
connectedPeripheral.firmwareVersion = (fetchedNode[0].myInfo?.firmwareVersion ?? "Unknown")
}
} catch {
print("Fetch NodeInfo Failed")
}
lastConnectedPeripheral = peripheral.identifier.uuidString
// Discover Services
@ -257,19 +261,19 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
// Seems to be what is received when a tbeam sleeps, immediately recconnecting does not work.
lastConnectionError = e.localizedDescription
self.connectedNode = nil
print("BLE Disconnected: \(peripheral.name ?? "Unknown") Error Code: \(errorCode) Error: \(e.localizedDescription)")
if meshLoggingEnabled { MeshLogger.log("BLE Disconnected: \(peripheral.name ?? "Unknown") Error Code: \(errorCode) Error: \(e.localizedDescription)") }
} else if errorCode == 14 { // Peer removed pairing information
// Forgetting and reconnecting seems to be necessary so we need to show the user an error telling them to do that
lastConnectionError = "\(e.localizedDescription) This error usually cannot be fixed without forgetting the device unders Settings > Bluetooth and re-connecting to the radio."
self.connectedNode = nil
if meshLoggingEnabled { MeshLogger.log("BLE Disconnected: \(peripheral.name ?? "Unknown") Error Code: \(errorCode) Error: \(lastConnectionError)") }
} else {
lastConnectionError = e.localizedDescription
self.connectedNode = nil
print("BLE Disconnected: \(peripheral.name ?? "Unknown") Error Code: \(errorCode) Error: \(e.localizedDescription)")
if meshLoggingEnabled { MeshLogger.log("BLE Disconnected: \(peripheral.name ?? "Unknown") Error Code: \(errorCode) Error: \(e.localizedDescription)") }
}
@ -277,7 +281,6 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
// Disconnected without error which indicates user intent to disconnect
// Happens when swiping to disconnect
self.connectedNode = nil
if meshLoggingEnabled { MeshLogger.log("BLE Disconnected: \(peripheral.name ?? "Unknown"): User Initiated Disconnect") }
print("BLE Disconnected: \(peripheral.name ?? "Unknown"): User Initiated Disconnect")
}
@ -394,7 +397,6 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
let fetchMyInfoRequest:NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "MyInfoEntity")
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %i", Int64(decodedInfo.myInfo.myNodeNum))
do {
let fetchedMyInfo = try context?.fetch(fetchMyInfoRequest) as! [MyInfoEntity]
// Not Found Insert
@ -407,28 +409,36 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
myInfo.messageTimeoutMsec = Int32(bitPattern: decodedInfo.myInfo.messageTimeoutMsec)
myInfo.minAppVersion = Int32(bitPattern: decodedInfo.myInfo.minAppVersion)
myInfo.maxChannels = Int32(bitPattern: decodedInfo.myInfo.maxChannels)
do {
try context!.save()
print("Saved a myInfo for \(decodedInfo.myInfo.myNodeNum)")
} catch {
context!.rollback()
let nsError = error as NSError
print("Error Saving CoreData MyInfoEntity: \(nsError)")
}
}
else {
fetchedMyInfo[0].myNodeNum = Int64(decodedInfo.myInfo.myNodeNum)
fetchedMyInfo[0].hasGps = decodedInfo.myInfo.hasGps_p
fetchedMyInfo[0].numBands = Int32(bitPattern: decodedInfo.myInfo.numBands)
fetchedMyInfo[0].firmwareVersion = decodedInfo.myInfo.firmwareVersion
fetchedMyInfo[0].messageTimeoutMsec = Int32(bitPattern: decodedInfo.myInfo.messageTimeoutMsec)
fetchedMyInfo[0].minAppVersion = Int32(bitPattern: decodedInfo.myInfo.minAppVersion)
fetchedMyInfo[0].maxChannels = Int32(bitPattern: decodedInfo.myInfo.maxChannels)
}
do {
try context!.save()
print("Saved a myInfo for \(decodedInfo.myInfo.myNodeNum)")
if meshLoggingEnabled { MeshLogger.log("BLE FROMRADIO received and myInfo saved for \(peripheral.name ?? String(decodedInfo.myInfo.myNodeNum))") }
} catch {
context!.rollback()
let nsError = error as NSError
print("Error Saving CoreData MyInfoEntity: \(nsError)")
}
} catch {
print("Fetch MyInfo Error")
}
//if meshLoggingEnabled { MeshLogger.log("BLE FROMRADIO received and myInfo saved for \(peripheral.name ?? String(myInfo.myNodeNum))") }
}
// NodeInfo Data
@ -437,9 +447,10 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
print("Save a CoreData NodeInfoEntity")
let fetchNodeRequest:NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "NodeInfoEntity")
fetchNodeRequest.predicate = NSPredicate(format: "num == %i", Int64(decodedInfo.nodeInfo.num))
fetchNodeRequest.predicate = NSPredicate(format: "num == %lld", Int64(decodedInfo.nodeInfo.num))
do {
let fetchedNode = try context?.fetch(fetchNodeRequest) as! [NodeInfoEntity]
// Not Found Insert
if fetchedNode.isEmpty {
@ -466,19 +477,19 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
newNode.user = newUser
}
if decodedInfo.nodeInfo.hasPosition && decodedInfo.nodeInfo.position.latitudeI != 0 {
let position = PositionEntity(context: context!)
position.latitudeI = decodedInfo.nodeInfo.position.latitudeI
position.longitudeI = decodedInfo.nodeInfo.position.longitudeI
position.altitude = decodedInfo.nodeInfo.position.altitude
position.batteryLevel = decodedInfo.nodeInfo.position.batteryLevel
position.time = Int32(bitPattern: decodedInfo.nodeInfo.position.time)
var newPostions = [PositionEntity]()
newPostions.append(position)
newNode.positions? = NSOrderedSet(array : newPostions)
}
// if false && decodedInfo.nodeInfo.hasPosition && decodedInfo.nodeInfo.position.latitudeI != 0 {
//
// let position = PositionEntity(context: context!)
// position.latitudeI = decodedInfo.nodeInfo.position.latitudeI
// position.longitudeI = decodedInfo.nodeInfo.position.longitudeI
// position.altitude = decodedInfo.nodeInfo.position.altitude
// position.batteryLevel = decodedInfo.nodeInfo.position.batteryLevel
// position.time = Int32(bitPattern: decodedInfo.nodeInfo.position.time)
//
// var newPostions = [PositionEntity]()
// newPostions.append(position)
// newNode.positions? = NSOrderedSet(array : newPostions)
// }
// Look for a MyInfo
let fetchMyInfoRequest:NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "MyInfoEntity")
@ -498,44 +509,44 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
} else {
let updatedNode = fetchedNode[0]
updatedNode.timestamp = Date()
updatedNode.lastHeard = Int32(bitPattern: decodedInfo.nodeInfo.lastHeard)
updatedNode.snr = decodedInfo.nodeInfo.snr
fetchedNode[0].id = Int64(decodedInfo.nodeInfo.num)
fetchedNode[0].num = Int64(decodedInfo.nodeInfo.num)
fetchedNode[0].timestamp = Date()
fetchedNode[0].lastHeard = Int32(bitPattern: decodedInfo.nodeInfo.lastHeard)
fetchedNode[0].snr = decodedInfo.nodeInfo.snr
if decodedInfo.nodeInfo.hasUser {
updatedNode.user!.userId = decodedInfo.nodeInfo.user.id
updatedNode.user!.longName = decodedInfo.nodeInfo.user.longName
updatedNode.user!.shortName = decodedInfo.nodeInfo.user.shortName
updatedNode.user!.hwModel = String(describing: decodedInfo.nodeInfo.user.hwModel).uppercased()
}
if decodedInfo.nodeInfo.hasPosition {
let position = PositionEntity(context: context!)
position.latitudeI = decodedInfo.nodeInfo.position.latitudeI
position.longitudeI = decodedInfo.nodeInfo.position.longitudeI
position.altitude = decodedInfo.nodeInfo.position.altitude
position.batteryLevel = decodedInfo.nodeInfo.position.batteryLevel
position.time = Int32(bitPattern: decodedInfo.nodeInfo.position.time)
if position.latitudeI != 0 {
let mutablePositions = updatedNode.positions!.mutableCopy() as! NSMutableOrderedSet
mutablePositions.add(position)
updatedNode.positions = mutablePositions.copy() as? NSOrderedSet
}
fetchedNode[0].user!.userId = decodedInfo.nodeInfo.user.id
fetchedNode[0].user!.longName = decodedInfo.nodeInfo.user.longName
fetchedNode[0].user!.shortName = decodedInfo.nodeInfo.user.shortName
fetchedNode[0].user!.hwModel = String(describing: decodedInfo.nodeInfo.user.hwModel).uppercased()
}
// if decodedInfo.nodeInfo.hasPosition {
//
// let position = PositionEntity(context: context!)
// position.latitudeI = decodedInfo.nodeInfo.position.latitudeI
// position.longitudeI = decodedInfo.nodeInfo.position.longitudeI
// position.altitude = decodedInfo.nodeInfo.position.altitude
// position.batteryLevel = decodedInfo.nodeInfo.position.batteryLevel
// position.time = Int32(bitPattern: decodedInfo.nodeInfo.position.time)
//
// if position.latitudeI != 0 {
// let mutablePositions = fetchedNode[0].positions!.mutableCopy() as! NSMutableOrderedSet
// mutablePositions.add(position)
// fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet
// }
// }
// Look for a MyInfo
let fetchMyInfoRequest:NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "MyInfoEntity")
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %i", Int64(decodedInfo.nodeInfo.num))
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(decodedInfo.nodeInfo.num))
do {
let fetchedMyInfo = try context?.fetch(fetchMyInfoRequest) as! [MyInfoEntity]
if fetchedMyInfo.count > 0 {
updatedNode.myInfo = fetchedMyInfo[0]
fetchedNode[0].myInfo = fetchedMyInfo[0]
}
} catch {
@ -570,62 +581,6 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
print("BLE FROMRADIO received and nodeInfo saved for \(decodedInfo.nodeInfo.num)")
if meshLoggingEnabled { MeshLogger.log("BLE FROMRADIO received and nodeInfo saved for \(decodedInfo.nodeInfo.num)") }
}
// if meshData.nodes.contains(where: {$0.id == decodedInfo.nodeInfo.num}) {
//
// // Found a matching node lets update it
// let nodeMatch = meshData.nodes.first(where: { $0.id == decodedInfo.nodeInfo.num })
// if connectedPeripheral != nil && connectedPeripheral.myInfo?.myNodeNum == nodeMatch?.num {
// connectedNode = nodeMatch
// }
//
// if nodeMatch?.lastHeard ?? 0 < decodedInfo.nodeInfo.lastHeard && nodeMatch?.user != nil && nodeMatch?.user.longName.count ?? 0 > 0 {
// // The data coming from the device is newer
//
// let nodeIndex = meshData.nodes.firstIndex(where: { $0.id == decodedInfo.nodeInfo.num })
// meshData.nodes.remove(at: nodeIndex!)
// meshData.save()
//
// } else {
//
// // Data is older than what the app already has
// return
// }
// }
// Set the connected node if the nodeInfo is for the connected node.
// if connectedPeripheral != nil && connectedPeripheral.myInfo?.myNodeNum == decodedInfo.nodeInfo.num {
//
// let nodeMatch = meshData.nodes.first(where: { $0.id == decodedInfo.nodeInfo.num })
// if nodeMatch != nil {
// connectedNode = nodeMatch
// }
// }
if decodedInfo.nodeInfo.hasUser {
meshData.nodes.append(
NodeInfoModel(
num: decodedInfo.nodeInfo.num,
user: NodeInfoModel.User(
id: decodedInfo.nodeInfo.user.id,
longName: decodedInfo.nodeInfo.user.longName,
shortName: decodedInfo.nodeInfo.user.shortName,
// macaddr: decodedInfo.nodeInfo.user.macaddr,
hwModel: String(describing: decodedInfo.nodeInfo.user.hwModel)
.uppercased()),
position: NodeInfoModel.Position(
latitudeI: decodedInfo.nodeInfo.position.latitudeI,
longitudeI: decodedInfo.nodeInfo.position.longitudeI,
altitude: decodedInfo.nodeInfo.position.altitude,
batteryLevel: decodedInfo.nodeInfo.position.batteryLevel,
time: decodedInfo.nodeInfo.position.time),
lastHeard: decodedInfo.nodeInfo.lastHeard,
snr: decodedInfo.nodeInfo.snr)
)
meshData.save()
if meshLoggingEnabled { MeshLogger.log("BLE FROMRADIO received and nodeInfo saved for \(decodedInfo.nodeInfo.user.longName)") }
}
}
// Handle assorted app packets
if decodedInfo.packet.id != 0 {
@ -650,12 +605,12 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
let fetchedUsers = try context?.fetch(messageUsers) as! [UserEntity]
let newMessage = MessageEntity(context: context!)
newMessage.messageId = Int32(bitPattern: decodedInfo.packet.id)
newMessage.messageId = Int64(decodedInfo.packet.id)
newMessage.messageTimestamp = Int32(bitPattern: decodedInfo.packet.rxTime)
newMessage.receivedACK = false
newMessage.direction = "IN"
if decodedInfo.packet.to == broadcastNodeId {
if decodedInfo.packet.to == broadcastNodeId && fetchedUsers.count == 1 {
let bcu: UserEntity = UserEntity(context: context!)
bcu.shortName = "BC"
@ -693,11 +648,10 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
} catch {
print("Something went wrong: \(error)")
context!.rollback()
let nsError = error as NSError
print("Unresolved error \(nsError)")
print("Failed to save new MessageEntity \(nsError)")
}
} catch {
@ -706,41 +660,103 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
}
}
} else if decodedInfo.packet.decoded.portnum == PortNum.nodeinfoApp {
//let fetchNodeInfoAppRequest:NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "NodeInfoEntity")
//fetchNodeInfoAppRequest.predicate = NSPredicate(format: "num == %i", Int64(decodedInfo.packet.from))
let fetchNodeRequest:NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "NodeInfoEntity")
fetchNodeRequest.predicate = NSPredicate(format: "num == %lld", Int64(decodedInfo.packet.from))
do {
let fetchedNode = try context?.fetch(fetchNodeRequest) as! [NodeInfoEntity]
var updatedNode = meshData.nodes.first(where: {$0.id == decodedInfo.packet.from })
if updatedNode != nil {
if meshLoggingEnabled { MeshLogger.log("BLE FROMRADIO received for node info app for \(updatedNode!.user.longName)") }
}
if updatedNode != nil {
updatedNode!.snr = decodedInfo.packet.rxSnr
updatedNode!.lastHeard = decodedInfo.packet.rxTime
// updatedNode!.update(from: updatedNode!.data)
let nodeIndex = meshData.nodes.firstIndex(where: { $0.id == decodedInfo.packet.from })
meshData.nodes.remove(at: nodeIndex!)
meshData.nodes.append(updatedNode!)
meshData.save()
if meshLoggingEnabled { MeshLogger.log("MESH PACKET Updated NodeInfo SNR and Time from Node Info App Packet For: \(updatedNode!.user.longName)") }
print("Updated NodeInfo SNR and Time from Packet For: \(updatedNode!.user.longName)")
if fetchedNode.count == 1 {
fetchedNode[0].id = Int64(decodedInfo.nodeInfo.num)
fetchedNode[0].num = Int64(decodedInfo.nodeInfo.num)
fetchedNode[0].timestamp = Date()
fetchedNode[0].lastHeard = Int32(bitPattern: decodedInfo.packet.rxTime)
fetchedNode[0].snr = decodedInfo.packet.rxSnr
}
else {
return
}
do {
try context!.save()
if meshLoggingEnabled {
MeshLogger.log("MESH PACKET Updated NodeInfo SNR and Time from Node Info App Packet For: \(fetchedNode[0].num)")
}
print("Updated NodeInfo SNR and Time from Packet For: \(fetchedNode[0].num)")
} catch {
context!.rollback()
let nsError = error as NSError
print("Error Saving NodeInfoEntity from NODEINFO_APP \(nsError)")
}
} catch {
print("Error Fetching NodeInfoEntity for NODEINFO_APP")
}
print(decodedInfo.packet.decoded.payload)
} else if decodedInfo.packet.decoded.portnum == PortNum.positionApp {
var updatedNode = meshData.nodes.first(where: {$0.id == decodedInfo.packet.from })
let fetchNodePositionRequest:NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "NodeInfoEntity")
fetchNodePositionRequest.predicate = NSPredicate(format: "num == %lld", Int64(decodedInfo.packet.from))
do {
let fetchedNode = try context?.fetch(fetchNodePositionRequest) as! [NodeInfoEntity]
if updatedNode != nil {
updatedNode!.snr = decodedInfo.packet.rxSnr
updatedNode!.lastHeard = decodedInfo.packet.rxTime
// updatedNode!.update(from: updatedNode!.data)
let nodeIndex = meshData.nodes.firstIndex(where: { $0.id == decodedInfo.packet.from })
meshData.nodes.remove(at: nodeIndex!)
meshData.nodes.append(updatedNode!)
meshData.save()
if meshLoggingEnabled { MeshLogger.log("MESH PACKET Updated NodeInfo SNR and Time from Position App Packet For: \(updatedNode!.user.longName)") }
print("Updated Position SNR and Time from Packet For: \(updatedNode!.user.longName)")
if fetchedNode.count == 1 {
fetchedNode[0].id = Int64(decodedInfo.nodeInfo.num)
fetchedNode[0].num = Int64(decodedInfo.nodeInfo.num)
fetchedNode[0].timestamp = Date()
fetchedNode[0].lastHeard = Int32(bitPattern: decodedInfo.packet.rxTime)
fetchedNode[0].snr = decodedInfo.packet.rxSnr
}
else {
return
}
do {
try context!.save()
if meshLoggingEnabled {
MeshLogger.log("MESH PACKET Updated NodeInfo SNR and Time from Node Info App Packet For: \(fetchedNode[0].num)")
}
print("Updated NodeInfo SNR and Time from Packet For: \(fetchedNode[0].num)")
} catch {
context!.rollback()
let nsError = error as NSError
print("Error Saving NodeInfoEntity from NODEINFO_APP \(nsError)")
}
} catch {
print("Error Fetching NodeInfoEntity for NODEINFO_APP")
}
print("Postion Payload")
print(try decodedInfo.packet.jsonString())
print(decodedInfo.packet.decoded.payload)
// if meshLoggingEnabled {
// MeshLogger.log("MESH PACKET Updated NodeInfo SNR and Time from Position App Packet For: \(updatedNode.num)")
// }
// print("Updated NodeInfo SNR and Time from Packet For: \(updatedNode.num)")
//
// print("Postion Payload")
// print(try decodedInfo.packet.jsonString())
} else if decodedInfo.packet.decoded.portnum == PortNum.adminApp {
if meshLoggingEnabled { MeshLogger.log("MESH PACKET received for Admin App UNHANDLED \(try decodedInfo.packet.jsonString())") }
@ -804,17 +820,25 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
success = false
} else {
var longName: String = self.connectedPeripheral.name
var shortName: String = "???"
var nodeNum: UInt32 = self.connectedPeripheral.myInfo?.myNodeNum ?? 0
let newMessage = MessageEntity(context: context!)
newMessage.messageId = Int64(bitPattern: 0)
newMessage.messageTimestamp = Int32(Date().timeIntervalSince1970)
newMessage.receivedACK = false
newMessage.direction = "IN"
if connectedNode != nil {
longName = connectedNode.user.longName
shortName = connectedNode.user.shortName
nodeNum = connectedNode.num
}
let bcu: UserEntity = UserEntity(context: context!)
bcu.shortName = "BC"
bcu.longName = "Broadcast"
bcu.hwModel = "UNSET"
bcu.num = Int64(broadcastNodeId)
bcu.userId = "BROADCASTNODE"
newMessage.toUser = bcu
// Set from user from query here
let messageModel = MessageModel(messageId: 0, messageTimeStamp: UInt32(Date().timeIntervalSince1970), fromUserId: nodeNum, toUserId: broadcastNodeId, fromUserLongName: longName, toUserLongName: "Broadcast", fromUserShortName: shortName, toUserShortName: "BC", receivedACK: false, messagePayload: message, direction: "OUT")
newMessage.messagePayload = message
let dataType = PortNum.textMessageApp
let payloadData: Data = message.data(using: String.Encoding.utf8)!
@ -834,9 +858,20 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
let binaryData: Data = try! toRadio.serializedData()
if connectedPeripheral!.peripheral.state == CBPeripheralState.connected {
connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse)
//messageData.messages.append(messageModel)
//messageData.save()
success = true
do {
try context!.save()
print("Saved a new message for \(0)")
success = true
} catch {
context!.rollback()
let nsError = error as NSError
print("Unresolved error \(nsError)")
}
}
}
return success

View file

@ -21,6 +21,15 @@ struct MeshtasticClientApp: App {
.onChange(of: scenePhase) { (newScenePhase) in
switch newScenePhase {
case .background:
do {
try persistenceController.container.viewContext.save()
print("Saved viewContext when the app went to the background.")
} catch {
print("Failed to save viewContext when the app goes to the background.")
}
print("Scene is in the background")
case .inactive:
print("Scene is inactive")

View file

@ -4,15 +4,17 @@ import CoreBluetooth
struct Peripheral: Identifiable {
var id: String
var name: String
var firmwareVersion: String
var rssi: Int
var subscribed: Bool
var peripheral: CBPeripheral
var myInfo: MyInfoModel?
init(id: String, name: String, rssi: Int, subscribed: Bool, peripheral: CBPeripheral, myInfo: MyInfoModel?) {
init(id: String, name: String, firmwareVersion: String, rssi: Int, subscribed: Bool, peripheral: CBPeripheral, myInfo: MyInfoModel?) {
self.id = id
self.name = name
self.firmwareVersion = firmwareVersion
self.rssi = rssi
self.subscribed = subscribed
self.peripheral = peripheral

View file

@ -50,15 +50,15 @@ struct Connect: View {
VStack(alignment: .leading) {
if bleManager.connectedNode != nil {
if bleManager.connectedPeripheral != nil {
Text(bleManager.connectedNode.user.longName).font(.title2)
Text(bleManager.connectedPeripheral.name).font(.title2)
} else {
Text(String(bleManager.connectedPeripheral.peripheral.name ?? "Unknown")).font(.title2)
}
if bleManager.connectedNode != nil {
Text("Model: ").font(.caption)+Text(bleManager.connectedNode?.user.hwModel ?? "(null)").font(.caption).foregroundColor(Color.gray)
if bleManager.connectedPeripheral != nil {
//Text("Model: ").font(.caption)+Text(bleManager.connectedNode?.user!.hwModel ?? "(null)").font(.caption).foregroundColor(Color.gray)
}
Text("BLE Name: ").font(.caption)+Text(bleManager.connectedPeripheral.name).font(.caption).foregroundColor(Color.gray)
if bleManager.connectedPeripheral != nil {
@ -80,8 +80,8 @@ struct Connect: View {
.onChange(of: isPreferredRadio) { value in
if value {
if bleManager.connectedNode != nil {
let deviceName = "\(bleManager.connectedNode.user.longName) / \(bleManager.connectedPeripheral.peripheral.name ?? "")"
if bleManager.connectedPeripheral != nil {
let deviceName = (bleManager.connectedPeripheral.peripheral.name ?? "")
userSettings.preferredPeripheralName = deviceName
} else {
@ -201,7 +201,7 @@ struct Connect: View {
ZStack {
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedNode != nil) ? bleManager.connectedNode.user.shortName : ((bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.name : "Unknown") )
//ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedNode != nil) ? bleManager.connectedNode.user.shortName : ((bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.name : "Unknown") )
}
)
}

View file

@ -60,6 +60,6 @@ struct ContentView: View {
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(MeshData())
// .environmentObject(MeshData())
}
}

View file

@ -10,26 +10,23 @@ struct Channels: View {
NavigationView {
GeometryReader { _ in
NavigationLink(destination: Messages(), isActive: $isShowingDetailView) {
NavigationLink(destination: Messages(), isActive: $isShowingDetailView) {
List {
List {
HStack {
HStack {
Image(systemName: "dial.max.fill")
.font(.system(size: 62))
.symbolRenderingMode(.hierarchical)
.padding(.trailing)
.foregroundColor(.accentColor)
Image(systemName: "dial.max.fill")
.font(.system(size: 62))
.symbolRenderingMode(.hierarchical)
.padding(.trailing)
.foregroundColor(.accentColor)
Text("Primary")
.font(.largeTitle)
Text("Primary")
.font(.largeTitle)
}.padding()
}
}
}.padding()
}
}
.navigationTitle("Channels")
}
@ -39,8 +36,6 @@ struct Channels: View {
struct MessageList_Previews: PreviewProvider {
static let meshData = MeshData()
static var previews: some View {
Group {
Channels()

View file

@ -26,7 +26,7 @@ struct Messages: View {
@FocusState private var focusedField: Field?
@State var showDeleteMessageAlert = false
@State private var deleteMessageId: Int32 = 0
@State private var deleteMessageId: Int64 = 0
public var broadcastNodeId: UInt32 = 4294967295
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@ -48,15 +48,16 @@ struct Messages: View {
ForEach(messages) { message in
HStack(alignment: .top) {
let currentUser: Bool = false//(bleManager.connectedNode != nil) && ((bleManager.connectedNode.id) == message.fromUser!.num)
let currentUser: Bool = false// message.fromUser != nil && ((bleManager.connectedPeripheral) == message.fromUser!.num)
CircleText(text: "???", color: currentUser ? .accentColor : Color(.darkGray)).padding(.all, 5)
CircleText(text: (message.fromUser?.longName ?? "???"), color: currentUser ? .accentColor : Color(.darkGray)).padding(.all, 5)
.gesture(LongPressGesture(minimumDuration: 2)
.onEnded {_ in
print("I want to delete message: \(message.messageId)")
self.showDeleteMessageAlert = true
self.deleteMessageId = message.messageId
print(deleteMessageId)
})
VStack(alignment: .leading) {
@ -193,16 +194,17 @@ struct Messages: View {
}
}
.navigationTitle("Channel - Primary")
.navigationBarTitleDisplayMode(.inline)
//.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
ZStack {
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedNode != nil) ? bleManager.connectedNode.user.shortName : ((bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.name : "Unknown") )
//ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedNode != nil) ? bleManager.connectedNode.user.shortName : ((bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.name : "Unknown") )
})
.onAppear {
.onAppear(perform: {
self.bleManager.context = context
messageCount = messages.count
}
})
}
}

View file

@ -1,176 +1,176 @@
/*
Abstract:
A view showing the details for a node.
*/
import SwiftUI
import MapKit
import CoreLocation
struct NodeDetail: View {
@EnvironmentObject var bleManager: BLEManager
var node: NodeInfoModel
struct MapLocation: Identifiable {
let id = UUID()
let name: String
let coordinate: CLLocationCoordinate2D
}
var body: some View {
GeometryReader { bounds in
VStack {
if node.position.coordinate != nil {
let nodeCoordinatePosition = CLLocationCoordinate2D(latitude: node.position.latitude!, longitude: node.position.longitude!)
let regionBinding = Binding<MKCoordinateRegion>(
get: {
MKCoordinateRegion(center: nodeCoordinatePosition, span: MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005))
},
set: { _ in }
)
let annotations = [MapLocation(name: node.user.shortName, coordinate: node.position.coordinate!)]
Map(coordinateRegion: regionBinding, showsUserLocation: true, userTrackingMode: .none, annotationItems: annotations) { location in
MapAnnotation(
coordinate: location.coordinate,
content: {
CircleText(text: node.user.shortName, color: .accentColor)
}
)
}.frame(idealWidth: bounds.size.width, minHeight: bounds.size.height / 2)
} else {
Image(node.user.hwModel)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: bounds.size.width, height: bounds.size.height / 2)
}
ScrollView {
HStack {
VStack(alignment: .center) {
Text("AKA").font(.title2).fixedSize()
CircleText(text: node.user.shortName, color: .accentColor)
.offset(y: 10)
}
.padding([.leading, .trailing, .bottom])
Divider()
if node.snr != nil && node.snr! > 0 {
VStack(alignment: .center) {
Image(systemName: "waveform.path")
.font(.title)
.foregroundColor(.accentColor)
.symbolRenderingMode(.hierarchical)
Text("SNR").font(.title2).fixedSize()
Text(String(node.snr ?? 0))
.font(.title2)
.foregroundColor(.gray)
}
Divider()
}
VStack(alignment: .center) {
BatteryIcon(batteryLevel: node.position.batteryLevel, font: .title, color: .accentColor)
if node.position.batteryLevel != nil && node.position.batteryLevel! > 0 {
Text("Battery").font(.title2).fixedSize()
Text(String(node.position.batteryLevel!) + "%")
.font(.title2)
.foregroundColor(.gray)
.symbolRenderingMode(.hierarchical)
} else {
Text("Powered").font(.title2)
}
}
}.padding(4)
Divider()
HStack {
Image(node.user.hwModel)
.resizable()
.frame(width: 60, height: 60)
.cornerRadius(5)
Text("Model: " + String(node.user.hwModel))
.font(.title3)
}
.padding()
Divider()
if node.lastHeard > 0 {
HStack {
Image(systemName: "clock").font(.title2).foregroundColor(.accentColor)
let lastHeard = Date(timeIntervalSince1970: TimeInterval(node.lastHeard))
Text("Last Heard: \(lastHeard, style: .relative) ago").font(.title3)
}.padding()
Divider()
}
if node.position.coordinate != nil {
HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/, spacing: 14) {
Image(systemName: "mappin").font(.title).foregroundColor(.accentColor)
VStack(alignment: .leading) {
Text("Latitude").font(.headline)
Text(String(node.position.latitude ?? 0)).font(.caption).foregroundColor(.gray)
}
Divider()
VStack(alignment: .leading) {
Text("Longitude").font(.headline)
Text(String(node.position.longitude ?? 0)).font(.caption).foregroundColor(.gray)
}
Divider()
VStack(alignment: .leading) {
Text("Altitude").font(.headline)
Text(String(node.position.altitude ?? 0) + " m").font(.caption).foregroundColor(.gray)
}
}.padding()
Divider()
}
HStack(alignment: .center) {
VStack {
HStack {
Image(systemName: "person").font(.title3).foregroundColor(.accentColor)
Text("Unique Id:").font(.title3)
}
Text(node.user.id).font(.headline).foregroundColor(.gray)
}
Divider()
VStack {
HStack {
Image(systemName: "number").font(.title3).foregroundColor(.accentColor)
Text("Node Number:").font(.title3)
}
Text(String(node.num)).font(.headline).foregroundColor(.gray)
}
}.padding()
}
}.navigationTitle(node.user.longName)
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
ZStack {
// ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedNode != nil) ? bleManager.connectedNode.user.shortName : ((bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.name : "Unknown") )
})
}.ignoresSafeArea(.all, edges: [.leading, .trailing])
}
}
struct NodeDetail_Previews: PreviewProvider {
static let bleManager = BLEManager()
static var previews: some View {
Group {
NodeDetail(node: bleManager.meshData.nodes[0])
NodeDetail(node: bleManager.meshData.nodes[1])
}
}
}
///*
//Abstract:
//A view showing the details for a node.
//*/
//
//import SwiftUI
//import MapKit
//import CoreLocation
//
//struct NodeDetail: View {
//
// @EnvironmentObject var bleManager: BLEManager
//
// var node: NodeInfoModel
//
// struct MapLocation: Identifiable {
// let id = UUID()
// let name: String
// let coordinate: CLLocationCoordinate2D
// }
//
// var body: some View {
//
// GeometryReader { bounds in
//
// VStack {
//
// if node.position.coordinate != nil {
//
// let nodeCoordinatePosition = CLLocationCoordinate2D(latitude: node.position.latitude!, longitude: node.position.longitude!)
//
// let regionBinding = Binding<MKCoordinateRegion>(
// get: {
// MKCoordinateRegion(center: nodeCoordinatePosition, span: MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005))
// },
// set: { _ in }
// )
// let annotations = [MapLocation(name: node.user.shortName, coordinate: node.position.coordinate!)]
//
// Map(coordinateRegion: regionBinding, showsUserLocation: true, userTrackingMode: .none, annotationItems: annotations) { location in
// MapAnnotation(
// coordinate: location.coordinate,
// content: {
// CircleText(text: node.user.shortName, color: .accentColor)
// }
// )
// }.frame(idealWidth: bounds.size.width, minHeight: bounds.size.height / 2)
// } else {
// Image(node.user.hwModel)
// .resizable()
// .aspectRatio(contentMode: .fit)
// .frame(width: bounds.size.width, height: bounds.size.height / 2)
// }
// ScrollView {
//
// HStack {
//
// VStack(alignment: .center) {
// Text("AKA").font(.title2).fixedSize()
// CircleText(text: node.user.shortName, color: .accentColor)
// .offset(y: 10)
// }
// .padding([.leading, .trailing, .bottom])
// Divider()
// if node.snr != nil && node.snr! > 0 {
// VStack(alignment: .center) {
//
// Image(systemName: "waveform.path")
// .font(.title)
// .foregroundColor(.accentColor)
// .symbolRenderingMode(.hierarchical)
// Text("SNR").font(.title2).fixedSize()
// Text(String(node.snr ?? 0))
// .font(.title2)
// .foregroundColor(.gray)
// }
// Divider()
// }
// VStack(alignment: .center) {
// BatteryIcon(batteryLevel: node.position.batteryLevel, font: .title, color: .accentColor)
// if node.position.batteryLevel != nil && node.position.batteryLevel! > 0 {
// Text("Battery").font(.title2).fixedSize()
// Text(String(node.position.batteryLevel!) + "%")
// .font(.title2)
// .foregroundColor(.gray)
// .symbolRenderingMode(.hierarchical)
// } else {
// Text("Powered").font(.title2)
// }
// }
//
// }.padding(4)
// Divider()
// HStack {
//
// Image(node.user.hwModel)
// .resizable()
// .frame(width: 60, height: 60)
// .cornerRadius(5)
//
// Text("Model: " + String(node.user.hwModel))
// .font(.title3)
// }
// .padding()
// Divider()
//
// if node.lastHeard > 0 {
//
// HStack {
//
// Image(systemName: "clock").font(.title2).foregroundColor(.accentColor)
// let lastHeard = Date(timeIntervalSince1970: TimeInterval(node.lastHeard))
// Text("Last Heard: \(lastHeard, style: .relative) ago").font(.title3)
// }.padding()
// Divider()
// }
//
// if node.position.coordinate != nil {
// HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/, spacing: 14) {
// Image(systemName: "mappin").font(.title).foregroundColor(.accentColor)
// VStack(alignment: .leading) {
// Text("Latitude").font(.headline)
// Text(String(node.position.latitude ?? 0)).font(.caption).foregroundColor(.gray)
// }
// Divider()
// VStack(alignment: .leading) {
// Text("Longitude").font(.headline)
// Text(String(node.position.longitude ?? 0)).font(.caption).foregroundColor(.gray)
// }
// Divider()
// VStack(alignment: .leading) {
// Text("Altitude").font(.headline)
// Text(String(node.position.altitude ?? 0) + " m").font(.caption).foregroundColor(.gray)
// }
// }.padding()
// Divider()
// }
// HStack(alignment: .center) {
// VStack {
// HStack {
// Image(systemName: "person").font(.title3).foregroundColor(.accentColor)
// Text("Unique Id:").font(.title3)
// }
// Text(node.user.id).font(.headline).foregroundColor(.gray)
// }
// Divider()
// VStack {
// HStack {
// Image(systemName: "number").font(.title3).foregroundColor(.accentColor)
// Text("Node Number:").font(.title3)
// }
// Text(String(node.num)).font(.headline).foregroundColor(.gray)
// }
// }.padding()
// }
// }.navigationTitle(node.user.longName)
// .navigationBarTitleDisplayMode(.inline)
// .navigationBarItems(trailing:
//
// ZStack {
// // ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedNode != nil) ? bleManager.connectedNode.user.shortName : ((bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.name : "Unknown") )
// })
// }.ignoresSafeArea(.all, edges: [.leading, .trailing])
// }
//}
//
//struct NodeDetail_Previews: PreviewProvider {
// static let bleManager = BLEManager()
//
// static var previews: some View {
// Group {
// NodeDetail(node: bleManager.meshData.nodes[0])
// NodeDetail(node: bleManager.meshData.nodes[1])
// }
// }
//}

View file

@ -0,0 +1,175 @@
/*
Abstract:
A view showing the details for a node.
*/
import SwiftUI
import MapKit
import CoreLocation
struct NodeInfoEntityDetail: View {
@EnvironmentObject var bleManager: BLEManager
var node: NodeInfoEntity
struct MapLocation: Identifiable {
let id = UUID()
let name: String
let coordinate: CLLocationCoordinate2D
}
var body: some View {
GeometryReader { bounds in
VStack {
if node.positions!.count > 0 {
// let nodeCoordinatePosition = CLLocationCoordinate2D(latitude: node.position.latitude!, longitude: node.position.longitude!)
//
// let regionBinding = Binding<MKCoordinateRegion>(
// get: {
// MKCoordinateRegion(center: nodeCoordinatePosition, span: MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005))
// },
// set: { _ in }
// )
// let annotations = [MapLocation(name: node.user.shortName, coordinate: node.position.coordinate!)]
//
// Map(coordinateRegion: regionBinding, showsUserLocation: true, userTrackingMode: .none, annotationItems: annotations) { location in
// MapAnnotation(
// coordinate: location.coordinate,
// content: {
// CircleText(text: node.user.shortName, color: .accentColor)
// }
// )
// }.frame(idealWidth: bounds.size.width, minHeight: bounds.size.height / 2)
} else {
Image(node.user?.hwModel ?? "UNSET")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: bounds.size.width, height: bounds.size.height / 2)
}
ScrollView {
HStack {
VStack(alignment: .center) {
Text("AKA").font(.title2).fixedSize()
CircleText(text: node.user?.shortName ?? "???", color: .accentColor)
.offset(y: 10)
}
.padding([.leading, .trailing, .bottom])
Divider()
if node.snr > 0 {
VStack(alignment: .center) {
Image(systemName: "waveform.path")
.font(.title)
.foregroundColor(.accentColor)
.symbolRenderingMode(.hierarchical)
Text("SNR").font(.title2).fixedSize()
Text(String(node.snr))
.font(.title2)
.foregroundColor(.gray)
}
Divider()
}
// VStack(alignment: .center) {
// BatteryIcon(batteryLevel: node.position.batteryLevel, font: .title, color: .accentColor)
// if node.position.batteryLevel != nil && node.position.batteryLevel! > 0 {
// Text("Battery").font(.title2).fixedSize()
// Text(String(node.position.batteryLevel!) + "%")
// .font(.title2)
// .foregroundColor(.gray)
// .symbolRenderingMode(.hierarchical)
// } else {
// Text("Powered").font(.title2)
// }
// }
}.padding(4)
Divider()
HStack {
Image(node.user!.hwModel ?? "UNSET")
.resizable()
.frame(width: 60, height: 60)
.cornerRadius(5)
Text("Model: " + String(node.user!.hwModel ?? "UNSET"))
.font(.title3)
}
.padding()
Divider()
if node.lastHeard > 0 {
HStack {
Image(systemName: "clock").font(.title2).foregroundColor(.accentColor)
let lastHeard = Date(timeIntervalSince1970: TimeInterval(node.lastHeard))
Text("Last Heard: \(lastHeard, style: .relative) ago").font(.title3)
}.padding()
Divider()
}
// if node.position.coordinate != nil {
// HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/, spacing: 14) {
// Image(systemName: "mappin").font(.title).foregroundColor(.accentColor)
// VStack(alignment: .leading) {
// Text("Latitude").font(.headline)
// Text(String(node.position.latitude ?? 0)).font(.caption).foregroundColor(.gray)
// }
// Divider()
// VStack(alignment: .leading) {
// Text("Longitude").font(.headline)
// Text(String(node.position.longitude ?? 0)).font(.caption).foregroundColor(.gray)
// }
// Divider()
// VStack(alignment: .leading) {
// Text("Altitude").font(.headline)
// Text(String(node.position.altitude ?? 0) + " m").font(.caption).foregroundColor(.gray)
// }
// }.padding()
// Divider()
// }
HStack(alignment: .center) {
VStack {
HStack {
Image(systemName: "person").font(.title3).foregroundColor(.accentColor)
Text("Unique Id:").font(.title3)
}
Text(node.user?.userId ?? "??????").font(.headline).foregroundColor(.gray)
}
Divider()
VStack {
HStack {
Image(systemName: "number").font(.title3).foregroundColor(.accentColor)
Text("Node Number:").font(.title3)
}
Text(String(node.num)).font(.headline).foregroundColor(.gray)
}
}.padding()
}
}.navigationTitle(node.user!.longName ?? "Unknown")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
ZStack {
// ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedNode != nil) ? bleManager.connectedNode.user.shortName : ((bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.name : "Unknown") )
})
}.ignoresSafeArea(.all, edges: [.leading, .trailing])
}
}
struct NodeInfoEntityDetail_Previews: PreviewProvider {
static let bleManager = BLEManager()
static var previews: some View {
Group {
//NodeInfoEntityDetail(node: node)
}
}
}

View file

@ -11,89 +11,64 @@
import SwiftUI
struct NodeList: View {
@EnvironmentObject var bleManager: BLEManager
// CoreData
@Environment(\.managedObjectContext) var context
@EnvironmentObject var bleManager: BLEManager
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \NodeInfoEntity.lastHeard, ascending: false)],
animation: .default)
private var nodes: FetchedResults<NodeInfoEntity>
@State private var selection: String?
@State private var showLocationOnly = false
var filteredDevices: [NodeInfoModel] {
bleManager.meshData.nodes.filter { node in
(!showLocationOnly || node.position.coordinate != nil)
}
}
var body: some View {
NavigationView {
List {
if bleManager.meshData.nodes.count == 0 {
if nodes.count == 0 {
Text("Scan for Radios").font(.largeTitle)
// .listRowSeparator(.hidden)
Text("No LoRa Mesh Nodes Found").font(.title2)
// .listRowSeparator(.hidden)
Text("Go to the bluetooth section in the bottom right menu and click the Start Scanning button to scan for nearby radios and find your Meshtastic device. Make sure your device is powered on and near your phone or tablet.")
.font(.body)
// .listRowSeparator(.hidden)
Text("Once the device shows under Available Devices touch the device you want to connect to and it will pull node information over BLE and populate the node list and mesh map in the Meshtastic app.")
// .listRowSeparator(.hidden)
Text("Views with bluetooth functionality will show an indicator in the upper right hand corner show if bluetooth is on, and if a device is connected.")
// .listRowSeparator(.hidden)
Spacer()
// .listRowSeparator(.hidden)
} else {
Toggle(isOn: $showLocationOnly) {
Text("Nodes with location only")
}
ForEach(filteredDevices.sorted(by: { $0.lastHeard > $1.lastHeard })) { node in
ForEach( nodes ) { node in
let index = nodes.firstIndex(where: { $0.id == node.id })
NavigationLink(destination: NodeInfoEntityDetail(node: node), tag: String(index!), selection: $selection) {
let index = filteredDevices.sorted(by: { $0.lastHeard > $1.lastHeard }).firstIndex(where: { $0.id == node.id })
if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.myInfo != nil {
NavigationLink(destination: NodeDetail(node: node), tag: String(index!), selection: $selection) {
if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.myInfo != nil {
let connected: Bool = (bleManager.connectedPeripheral.myInfo!.myNodeNum == node.id)
NodeRow(node: node, connected: connected)
} else {
NodeRow(node: node, connected: false)
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
let nodeIndex = bleManager.meshData.nodes.firstIndex(where: { $0.num == node.num })
bleManager.meshData.nodes.remove(at: nodeIndex!)
bleManager.meshData.save()
} label: {
Label("Delete from app", systemImage: "trash")
let connected: Bool = (bleManager.connectedPeripheral.myInfo!.myNodeNum == node.id)
NodeInfoEntityRow(node: node, connected: connected)
} else {
NodeInfoEntityRow(node: node, connected: false)
}
}
}
}
}
}
}
.navigationTitle("All Nodes")
.onAppear(
perform: {
bleManager.meshData.load()
if UIDevice.current.userInterfaceIdiom == .pad {
if bleManager.meshData.nodes.count > 0 {
selection = "0"
}
.onAppear{
self.bleManager.context = context
if UIDevice.current.userInterfaceIdiom == .pad {
if nodes.count > 0 {
selection = "0"
}
}
)
}
}
.ignoresSafeArea(.all, edges: [.leading, .trailing])
//.ignoresSafeArea(.all, edges: [.leading, .trailing])
.navigationViewStyle(DoubleColumnNavigationViewStyle())
}
}
struct NodeList_Previews: PreviewProvider {
static var previews: some View {
NodeList()
}
}

View file

@ -12,13 +12,23 @@ import CoreLocation
struct NodeMap: View {
// CoreData
@Environment(\.managedObjectContext) var context
@EnvironmentObject var bleManager: BLEManager
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \NodeInfoEntity.lastHeard, ascending: false)],
animation: .default)
private var locationNodes: FetchedResults<NodeInfoEntity>
var locationNodes: [NodeInfoModel] {
bleManager.meshData.nodes.filter { node in
(node.position.coordinate != nil)
}
}
//var locationNodes: [NodeInfoModel]// {
//bleManager.meshData.nodes.filter { node in
// (node.position.coordinate != nil)
// }
//}
struct MapLocation: Identifiable {
let id = UUID()
let name: String
@ -39,20 +49,20 @@ struct NodeMap: View {
NavigationView {
ZStack {
Map(coordinateRegion: regionBinding,
interactionModes: [.all],
showsUserLocation: true,
userTrackingMode: .constant(.follow), annotationItems: locationNodes) { location in
MapAnnotation(
coordinate: location.position.coordinate!,
content: {
CircleText(text: location.user.shortName, color: .accentColor)
}
)
}
.frame(maxHeight: .infinity)
.ignoresSafeArea(.all, edges: [.leading, .trailing])
// Map(coordinateRegion: regionBinding,
// interactionModes: [.all],
// showsUserLocation: true,
// userTrackingMode: .constant(.follow), annotationItems: locationNodes) { location in
//
// MapAnnotation(
// coordinate: location.position.coordinate!,
// content: {
// CircleText(text: location.user.shortName, color: .accentColor)
// }
// )
// }
// .frame(maxHeight: .infinity)
// .ignoresSafeArea(.all, edges: [.leading, .trailing])
}
.navigationTitle("Mesh Map")
.navigationBarTitleDisplayMode(.inline)

View file

@ -1,64 +1,64 @@
import SwiftUI
struct NodeRow: View {
var node: NodeInfoModel
var connected: Bool
var body: some View {
VStack(alignment: .leading) {
HStack {
CircleText(text: node.user.shortName, color: Color.accentColor).offset(y: 1).padding(.trailing, 5)
.offset(x: -15)
if UIDevice.current.userInterfaceIdiom == .pad {
Text(node.user.longName).font(.headline)
.offset(x: -15)
} else {
Text(node.user.longName).font(.title)
.offset(x: -15)
}
}
.padding(.bottom, 10)
HStack(alignment: .bottom) {
Image(systemName: "clock.badge.checkmark.fill").font(.headline).foregroundColor(.accentColor).symbolRenderingMode(.hierarchical)
if UIDevice.current.userInterfaceIdiom == .pad {
if connected {
Text("Currently Connected").font(.caption).foregroundColor(Color.accentColor)
} else if node.lastHeard > 0 {
let lastHeard = Date(timeIntervalSince1970: TimeInterval(node.lastHeard))
Text("Last Heard: \(lastHeard, style: .relative) ago").font(.caption).foregroundColor(.gray)
} else {
Text("Last Heard: Unknown").font(.caption).foregroundColor(.gray)
}
} else {
if connected {
Text("Currently Connected").font(.subheadline).foregroundColor(Color.accentColor)
} else if node.lastHeard > 0 {
let lastHeard = Date(timeIntervalSince1970: TimeInterval(node.lastHeard))
Text("Last Heard: \(lastHeard, style: .relative) ago").font(.subheadline).foregroundColor(.gray)
} else {
Text("Last Heard: Unknown").font(.subheadline).foregroundColor(.gray)
}
}
}
}.padding([.leading, .top, .bottom])
}
}
struct NodeRow_Previews: PreviewProvider {
static var nodes = BLEManager().meshData.nodes
static var previews: some View {
Group {
NodeRow(node: nodes[0], connected: true)
}
.previewLayout(.fixed(width: 300, height: 70))
}
}
//import SwiftUI
//
//struct NodeRow: View {
// var node: NodeInfoModel
// var connected: Bool
//
// var body: some View {
// VStack(alignment: .leading) {
//
// HStack {
//
// CircleText(text: node.user.shortName, color: Color.accentColor).offset(y: 1).padding(.trailing, 5)
// .offset(x: -15)
//
// if UIDevice.current.userInterfaceIdiom == .pad {
// Text(node.user.longName).font(.headline)
// .offset(x: -15)
// } else {
// Text(node.user.longName).font(.title)
// .offset(x: -15)
// }
// }
// .padding(.bottom, 10)
//
// HStack(alignment: .bottom) {
//
// Image(systemName: "clock.badge.checkmark.fill").font(.headline).foregroundColor(.accentColor).symbolRenderingMode(.hierarchical)
//
// if UIDevice.current.userInterfaceIdiom == .pad {
//
// if connected {
// Text("Currently Connected").font(.caption).foregroundColor(Color.accentColor)
// } else if node.lastHeard > 0 {
// let lastHeard = Date(timeIntervalSince1970: TimeInterval(node.lastHeard))
// Text("Last Heard: \(lastHeard, style: .relative) ago").font(.caption).foregroundColor(.gray)
// } else {
// Text("Last Heard: Unknown").font(.caption).foregroundColor(.gray)
// }
//
// } else {
// if connected {
// Text("Currently Connected").font(.subheadline).foregroundColor(Color.accentColor)
// } else if node.lastHeard > 0 {
// let lastHeard = Date(timeIntervalSince1970: TimeInterval(node.lastHeard))
// Text("Last Heard: \(lastHeard, style: .relative) ago").font(.subheadline).foregroundColor(.gray)
// } else {
// Text("Last Heard: Unknown").font(.subheadline).foregroundColor(.gray)
// }
// }
// }
// }.padding([.leading, .top, .bottom])
// }
//}
//
//struct NodeRow_Previews: PreviewProvider {
// static var nodes = BLEManager().meshData.nodes
//
// static var previews: some View {
// Group {
// NodeRow(node: nodes[0], connected: true)
// }
// .previewLayout(.fixed(width: 300, height: 70))
// }
//}