This commit is contained in:
Garth Vander Houwen 2026-04-20 03:30:33 -04:00 committed by GitHub
commit 2c30bfd7e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 3212 additions and 2630 deletions

View file

@ -7,6 +7,19 @@
objects = {
/* Begin PBXBuildFile section */
48E682ECE71246C3BFBD6B8F /* ChannelEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45E6EDE744E3446FA1084499 /* ChannelEntity.swift */; };
898C6C4E60B640B3A285AF25 /* ConfigModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B88B4AE298C43D1B8F4516C /* ConfigModels.swift */; };
B96123C9177242AFA768AA8F /* DeviceMetadataEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509A1C42A695463093654617 /* DeviceMetadataEntity.swift */; };
10D80CF7AD2B4269BE0A4933 /* MeshtasticSchema.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DF7D72AA0A450399DBC592 /* MeshtasticSchema.swift */; };
D75A5D73360C42D6B0CB2894 /* MessageEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3204EF969484E549C139730 /* MessageEntity.swift */; };
85D0F28AE7CE44C39B6BEBF8 /* MyInfoEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5970BC1CAF4B66999C7248 /* MyInfoEntity.swift */; };
9C744FC4C34549008EADCA4E /* NodeInfoEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7101240749F24E96835A040A /* NodeInfoEntity.swift */; };
D02589957BD040969FCB8663 /* PositionEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97241903A924144A3EEB679 /* PositionEntity.swift */; };
93FFCD83202042FB85B47CFD /* RouteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55D64F064C45ADBD31ABF8 /* RouteModels.swift */; };
5D3AFC6F08ED4C909C830C55 /* TelemetryEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31483AF5F5354D3481698E32 /* TelemetryEntity.swift */; };
7B1684B5E1AF4137829C03B8 /* TraceRouteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D66A4063FE4E868CD30D3C /* TraceRouteModels.swift */; };
70A5B362F7EC4F14A99A8C39 /* UserEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 918722D2C1474B2D99ED01DC /* UserEntity.swift */; };
E340A7CA75194A49AB8763C7 /* WaypointEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98797BFD67F449B9ACCEDA7 /* WaypointEntity.swift */; };
AA000401SIRI000000000002 /* AppIntentVocabulary.plist in Resources */ = {isa = PBXBuildFile; fileRef = AA000401SIRI000000000001 /* AppIntentVocabulary.plist */; };
AA000301CPTST000000000002 /* CarPlayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000301CPTST000000000001 /* CarPlayTests.swift */; };
AA000201CPLAY000000000002 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000201CPLAY000000000001 /* CarPlaySceneDelegate.swift */; };
@ -40,9 +53,6 @@
233E99C52D84A0B600CC3A77 /* CompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99C42D84A0B600CC3A77 /* CompactWidget.swift */; };
233E99C72D84A70900CC3A77 /* SoilCompactWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99C62D84A70900CC3A77 /* SoilCompactWidgets.swift */; };
233E99CB2D85AAA900CC3A77 /* RainfallCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99CA2D85AAA900CC3A77 /* RainfallCompactWidget.swift */; };
2344A2AB2D66974300170A77 /* ManagedAttributePropertyWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */; };
2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */; };
2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */; };
2344A2B12D68DFF800170A77 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C49D8F2C471AEA0024FBD1 /* Constants.swift */; };
2346A7192E2FB9A300CB9239 /* SerialConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2346A7182E2FB9A300CB9239 /* SerialConnection.swift */; };
2346A71D2E2FB9C500CB9239 /* SerialTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */; };
@ -361,6 +371,19 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
45E6EDE744E3446FA1084499 /* ChannelEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEntity.swift; sourceTree = "<group>"; };
4B88B4AE298C43D1B8F4516C /* ConfigModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigModels.swift; sourceTree = "<group>"; };
509A1C42A695463093654617 /* DeviceMetadataEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMetadataEntity.swift; sourceTree = "<group>"; };
38DF7D72AA0A450399DBC592 /* MeshtasticSchema.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticSchema.swift; sourceTree = "<group>"; };
F3204EF969484E549C139730 /* MessageEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageEntity.swift; sourceTree = "<group>"; };
DC5970BC1CAF4B66999C7248 /* MyInfoEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyInfoEntity.swift; sourceTree = "<group>"; };
7101240749F24E96835A040A /* NodeInfoEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntity.swift; sourceTree = "<group>"; };
A97241903A924144A3EEB679 /* PositionEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionEntity.swift; sourceTree = "<group>"; };
DB55D64F064C45ADBD31ABF8 /* RouteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteModels.swift; sourceTree = "<group>"; };
31483AF5F5354D3481698E32 /* TelemetryEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryEntity.swift; sourceTree = "<group>"; };
D8D66A4063FE4E868CD30D3C /* TraceRouteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraceRouteModels.swift; sourceTree = "<group>"; };
918722D2C1474B2D99ED01DC /* UserEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntity.swift; sourceTree = "<group>"; };
E98797BFD67F449B9ACCEDA7 /* WaypointEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaypointEntity.swift; sourceTree = "<group>"; };
AA000401SIRI000000000010 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = en; path = en.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
AA000401SIRI000000000011 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = de; path = de.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
AA000401SIRI000000000012 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = es; path = es.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
@ -407,9 +430,6 @@
233E99C42D84A0B600CC3A77 /* CompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactWidget.swift; sourceTree = "<group>"; };
233E99C62D84A70900CC3A77 /* SoilCompactWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoilCompactWidgets.swift; sourceTree = "<group>"; };
233E99CA2D85AAA900CC3A77 /* RainfallCompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RainfallCompactWidget.swift; sourceTree = "<group>"; };
2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedAttributePropertyWrapper.swift; sourceTree = "<group>"; };
2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataClass.swift"; sourceTree = "<group>"; };
2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataProperties.swift"; sourceTree = "<group>"; };
2346A7182E2FB9A300CB9239 /* SerialConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConnection.swift; sourceTree = "<group>"; };
2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialTransport.swift; sourceTree = "<group>"; };
2349A0492EAE4DA30060A581 /* ManualConnectionList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualConnectionList.swift; sourceTree = "<group>"; };
@ -834,15 +854,6 @@
path = "Compact Widgets";
sourceTree = "<group>";
};
2344A2AC2D66978000170A77 /* CoreData */ = {
isa = PBXGroup;
children = (
2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */,
2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */,
);
path = CoreData;
sourceTree = "<group>";
};
237AEB8D2E1FE120003B7CE3 /* Accessory */ = {
isa = PBXGroup;
children = (
@ -1027,7 +1038,6 @@
isa = PBXGroup;
children = (
108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */,
2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */,
DD58C5F12919AD3C00D5BEFB /* ChannelEntityExtension.swift */,
6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */,
DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */,
@ -1341,7 +1351,19 @@
DDC2E18826CE24EE0042C5E4 /* Model */ = {
isa = PBXGroup;
children = (
2344A2AC2D66978000170A77 /* CoreData */,
45E6EDE744E3446FA1084499 /* ChannelEntity.swift */,
4B88B4AE298C43D1B8F4516C /* ConfigModels.swift */,
509A1C42A695463093654617 /* DeviceMetadataEntity.swift */,
38DF7D72AA0A450399DBC592 /* MeshtasticSchema.swift */,
F3204EF969484E549C139730 /* MessageEntity.swift */,
DC5970BC1CAF4B66999C7248 /* MyInfoEntity.swift */,
7101240749F24E96835A040A /* NodeInfoEntity.swift */,
A97241903A924144A3EEB679 /* PositionEntity.swift */,
DB55D64F064C45ADBD31ABF8 /* RouteModels.swift */,
31483AF5F5354D3481698E32 /* TelemetryEntity.swift */,
D8D66A4063FE4E868CD30D3C /* TraceRouteModels.swift */,
918722D2C1474B2D99ED01DC /* UserEntity.swift */,
E98797BFD67F449B9ACCEDA7 /* WaypointEntity.swift */,
231B3F1E2D0879BC0069A07D /* Metrics Visualization */,
DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */,
);
@ -1751,6 +1773,19 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
48E682ECE71246C3BFBD6B8F /* ChannelEntity.swift in Sources */,
898C6C4E60B640B3A285AF25 /* ConfigModels.swift in Sources */,
B96123C9177242AFA768AA8F /* DeviceMetadataEntity.swift in Sources */,
10D80CF7AD2B4269BE0A4933 /* MeshtasticSchema.swift in Sources */,
D75A5D73360C42D6B0CB2894 /* MessageEntity.swift in Sources */,
85D0F28AE7CE44C39B6BEBF8 /* MyInfoEntity.swift in Sources */,
9C744FC4C34549008EADCA4E /* NodeInfoEntity.swift in Sources */,
D02589957BD040969FCB8663 /* PositionEntity.swift in Sources */,
93FFCD83202042FB85B47CFD /* RouteModels.swift in Sources */,
5D3AFC6F08ED4C909C830C55 /* TelemetryEntity.swift in Sources */,
7B1684B5E1AF4137829C03B8 /* TraceRouteModels.swift in Sources */,
70A5B362F7EC4F14A99A8C39 /* UserEntity.swift in Sources */,
E340A7CA75194A49AB8763C7 /* WaypointEntity.swift in Sources */,
43C0CB306098FD005C2D489F /* IntentHandler.swift in Sources */,
AA000201CPLAY000000000002 /* CarPlaySceneDelegate.swift in Sources */,
AA000201CPLAY000000000004 /* CarPlayIntentDonation.swift in Sources */,
@ -1988,19 +2023,15 @@
DD6F657B2C6EC2900053C113 /* LockLegend.swift in Sources */,
DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */,
DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */,
2344A2AB2D66974300170A77 /* ManagedAttributePropertyWrapper.swift in Sources */,
BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */,
D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */,
BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */,
DDA9515E2BC6F56F00CEA535 /* IndoorAirQuality.swift in Sources */,
DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */,
2346A71D2E2FB9C500CB9239 /* SerialTransport.swift in Sources */,
DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */,
BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */,
DDC4C9FF2A8D982900CE201C /* DetectionSensorConfig.swift in Sources */,
2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */,
2349A04A2EAE4DA30060A581 /* ManualConnectionList.swift in Sources */,
2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */,
D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */,
237AEB8F2E1FE457003B7CE3 /* Transport.swift in Sources */,
DDDB26442AAC0206003AFCB7 /* NodeDetail.swift in Sources */,

View file

@ -9,6 +9,7 @@ import Foundation
import MeshtasticProtobufs
import CocoaMQTT
import OSLog
import SwiftData
extension AccessoryManager {
@ -76,7 +77,7 @@ extension AccessoryManager {
updateDevice(key: \.num, value: Int64(myNodeInfo.myNodeNum))
if let myInfoId = await MeshPackets.shared.myInfoPacket(myInfo: myNodeInfo, peripheralId: connectedDeviceId),
let myInfo = try? context.existingObject(with: myInfoId) as? MyInfoEntity {
let myInfo = try? context.model(for: myInfoId) as? MyInfoEntity {
if let bleName = myInfo.bleName {
updateDevice(key: \.name, value: bleName)
updateDevice(key: \.longName, value: bleName)
@ -116,7 +117,7 @@ extension AccessoryManager {
// TODO: nodeInfoPacket's channel: parameter is not used
// deferSave hard coded: No need to defer save when nodeInfoPacket is now happening off the main thread
if let nodeInfoId = await MeshPackets.shared.nodeInfoPacket(nodeInfo: nodeInfo, channel: 0, deferSave: false),
let nodeInfo = try? context.existingObject(with: nodeInfoId) as? NodeInfoEntity {
let nodeInfo = try? context.model(for: nodeInfoId) as? NodeInfoEntity {
if let activeDevice = activeConnection?.device, activeDevice.num == nodeInfo.num {
if let user = nodeInfo.user {
updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName ?? "?")
@ -201,6 +202,7 @@ extension AccessoryManager {
updateDevice(key: \.firmwareVersion, value: metadata.firmwareVersion)
await MeshPackets.shared.deviceMetadataPacket(metadata: metadata, fromNum: deviceNum)
Logger.transport.info("✅ [handleDeviceMetadata] deviceMetadataPacket completed for \(deviceNum.toHex(), privacy: .public)")
}
internal func tryClearExistingChannels() {
@ -210,15 +212,24 @@ extension AccessoryManager {
}
// Before we get started delete the existing channels from the myNodeInfo
let fetchMyInfoRequest = MyInfoEntity.fetchRequest()
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum))
let num = Int64(deviceNum)
let fetchMyInfoRequest = FetchDescriptor<MyInfoEntity>(predicate: #Predicate { $0.myNodeNum == num })
do {
let fetchedMyInfo = try context.fetch(fetchMyInfoRequest)
if fetchedMyInfo.count == 1 {
let mutableChannels = fetchedMyInfo[0].channels?.mutableCopy() as? NSMutableOrderedSet
mutableChannels?.removeAllObjects()
fetchedMyInfo[0].channels = mutableChannels
let channelsToDelete = fetchedMyInfo[0].channels
for channel in channelsToDelete {
context.delete(channel)
}
fetchedMyInfo[0].channels.removeAll()
// Clean orphaned channels from older app versions where channels were
// detached but not deleted, which can create duplicate rows in queries.
let allChannels = try context.fetch(FetchDescriptor<ChannelEntity>())
for channel in allChannels where channel.myInfoChannel == nil {
context.delete(channel)
}
do {
try context.save()
} catch {
@ -267,10 +278,11 @@ extension AccessoryManager {
routerNode.storeForwardConfig?.isRouter = storeAndForwardMessage.heartbeat.secondary == 0
routerNode.storeForwardConfig?.lastHeartbeat = Date()
} else {
let newConfig = StoreForwardConfigEntity(context: context)
let newConfig = StoreForwardConfigEntity()
newConfig.enabled = true
newConfig.isRouter = storeAndForwardMessage.heartbeat.secondary == 0
newConfig.lastHeartbeat = Date()
context.insert(newConfig)
routerNode.storeForwardConfig = newConfig
}
@ -296,8 +308,9 @@ extension AccessoryManager {
if routerNode.storeForwardConfig != nil {
routerNode.storeForwardConfig?.lastRequest = Int32(storeAndForwardMessage.history.lastRequest)
} else {
let newConfig = StoreForwardConfigEntity(context: context)
let newConfig = StoreForwardConfigEntity()
newConfig.lastRequest = Int32(storeAndForwardMessage.history.lastRequest)
context.insert(newConfig)
routerNode.storeForwardConfig = newConfig
}
@ -363,13 +376,14 @@ extension AccessoryManager {
return
}
var hopNodes: [TraceRouteHopEntity] = []
let connectedHop = TraceRouteHopEntity(context: context)
let connectedHop = TraceRouteHopEntity()
context.insert(connectedHop)
connectedHop.time = Date()
connectedHop.num = deviceNum
connectedHop.name = connectedNode.user?.longName ?? "???"
// If nil, set to unknown, INT8_MIN (-128) then divide by 4
connectedHop.snr = Float(routingMessage.snrBack.last ?? -128) / 4
if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
if let mostRecent = traceRoute?.node?.positions.last, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
connectedHop.altitude = mostRecent.altitude
connectedHop.latitudeI = mostRecent.latitudeI
connectedHop.longitudeI = mostRecent.longitudeI
@ -383,7 +397,8 @@ extension AccessoryManager {
if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 {
hopNode = createNodeInfo(num: Int64(node), context: context)
}
let traceRouteHop = TraceRouteHopEntity(context: context)
let traceRouteHop = TraceRouteHopEntity()
context.insert(traceRouteHop)
traceRouteHop.time = Date()
if routingMessage.snrTowards.count >= index + 1 {
traceRouteHop.snr = Float(routingMessage.snrTowards[index]) / 4
@ -392,7 +407,7 @@ extension AccessoryManager {
traceRouteHop.snr = -32
}
if let hn = hopNode, hn.hasPositions {
if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
if let mostRecent = hn.positions.last, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
traceRouteHop.altitude = mostRecent.altitude
traceRouteHop.latitudeI = mostRecent.latitudeI
traceRouteHop.longitudeI = mostRecent.longitudeI
@ -412,13 +427,14 @@ extension AccessoryManager {
let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized
routeString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> "
}
let destinationHop = TraceRouteHopEntity(context: context)
let destinationHop = TraceRouteHopEntity()
context.insert(destinationHop)
destinationHop.name = traceRoute?.node?.user?.longName ?? "Unknown".localized
destinationHop.time = Date()
// If nil, set to unknown, INT8_MIN (-128) then divide by 4
destinationHop.snr = Float(routingMessage.snrTowards.last ?? -128) / 4
destinationHop.num = traceRoute?.node?.num ?? 0
if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
if let mostRecent = traceRoute?.node?.positions.last, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
destinationHop.altitude = mostRecent.altitude
destinationHop.latitudeI = mostRecent.latitudeI
destinationHop.longitudeI = mostRecent.longitudeI
@ -439,7 +455,8 @@ extension AccessoryManager {
if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 {
hopNode = createNodeInfo(num: Int64(node), context: context)
}
let traceRouteHop = TraceRouteHopEntity(context: context)
let traceRouteHop = TraceRouteHopEntity()
context.insert(traceRouteHop)
traceRouteHop.time = Date()
traceRouteHop.back = true
if routingMessage.snrBack.count >= index + 1 {
@ -449,7 +466,7 @@ extension AccessoryManager {
traceRouteHop.snr = -32
}
if let hn = hopNode, hn.hasPositions {
if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
if let mostRecent = hn.positions.last, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
traceRouteHop.altitude = mostRecent.altitude
traceRouteHop.latitudeI = mostRecent.latitudeI
traceRouteHop.longitudeI = mostRecent.longitudeI
@ -474,7 +491,7 @@ extension AccessoryManager {
routeBackString += "\(connectedNode.user?.longName ?? String(connectedNode.num.toHex())) (\(snrBackLast != -32 ? String(snrBackLast) : "unknown ".localized)dB)"
traceRoute?.routeBackText = routeBackString
}
traceRoute?.hops = NSOrderedSet(array: hopNodes)
traceRoute?.hops = hopNodes
traceRoute?.time = Date()
if let tr = traceRoute {

View file

@ -8,6 +8,7 @@
import Foundation
import CocoaMQTT
import OSLog
@preconcurrency import SwiftData
import MeshtasticProtobufs
extension AccessoryManager {
@ -18,10 +19,12 @@ extension AccessoryManager {
return
}
let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest()
fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(deviceNum))
let nodeNum = Int64(deviceNum)
let descriptor = FetchDescriptor<NodeInfoEntity>(
predicate: #Predicate { $0.num == nodeNum }
)
do {
let fetchedNodeInfo = try context.fetch(fetchNodeInfoRequest)
let fetchedNodeInfo = try context.fetch(descriptor)
if fetchedNodeInfo.count == 1 {
// Subscribe to Mqtt Client Proxy if enabled
if fetchedNodeInfo[0].mqttConfig != nil && fetchedNodeInfo[0].mqttConfig?.enabled ?? false && fetchedNodeInfo[0].mqttConfig?.proxyToClientEnabled ?? false {

View file

@ -8,6 +8,7 @@
import Foundation
import MeshtasticProtobufs
import OSLog
@preconcurrency import SwiftData
extension AccessoryManager {
@ -280,8 +281,7 @@ extension AccessoryManager {
return
}
let messageUsers = UserEntity.fetchRequest()
messageUsers.predicate = NSPredicate(format: "num IN %@", [fromUserNum, Int64(toUserNum)])
let messageUsers = FetchDescriptor<UserEntity>(predicate: #Predicate { $0.num == fromUserNum || $0.num == toUserNum })
do {
let fetchedUsers = try context.fetch(messageUsers)
@ -290,7 +290,8 @@ extension AccessoryManager {
Logger.data.error("🚫 Message Users Not Found, Fail")
throw AccessoryError.ioFailed("🚫 Message Users Not Found, Fail")
} else if fetchedUsers.count >= 1 {
let newMessage = MessageEntity(context: context)
let newMessage = MessageEntity()
context.insert(newMessage)
newMessage.messageId = Int64(UInt32.random(in: UInt32(UInt8.max)..<UInt32.max))
newMessage.messageTimestamp = Int32(Date().timeIntervalSince1970)
newMessage.receivedACK = false
@ -340,7 +341,6 @@ extension AccessoryManager {
let contactString = try contact.serializedData().base64EncodedString()
try? await am.addContactFromURL(base64UrlString: contactString)
try context.save()
user.objectWillChange.send()
} catch {
Logger.services.error("Error inserting new contact and resending encrypted send failed message: \(error)")
}
@ -457,8 +457,7 @@ extension AccessoryManager {
var i: Int32 = 0
if addChannels {
let fetchMyInfoRequest = MyInfoEntity.fetchRequest()
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum))
let fetchMyInfoRequest = FetchDescriptor<MyInfoEntity>(predicate: #Predicate { $0.myNodeNum == deviceNum })
let fetchedMyInfo = try context.fetch(fetchMyInfoRequest)
if fetchedMyInfo.count != 1 {
@ -467,7 +466,7 @@ extension AccessoryManager {
// We are trying to add a channel so lets get the last index
myInfo = fetchedMyInfo[0]
i = Int32(myInfo.channels?.count ?? -1)
i = Int32(myInfo.channels.count)
// Bail out if the index is negative or bigger than our max of 8
if i < 0 || i > 8 {
@ -478,12 +477,8 @@ extension AccessoryManager {
for cs in channelSet.settings {
if addChannels {
guard let mutableChannels = myInfo.channels?.mutableCopy() as? NSMutableOrderedSet else {
throw AccessoryError.appError("No channels or channel")
}
// Bail out if there are no channels or if the same channel name already exists
if mutableChannels.first(where: { ($0 as AnyObject).name == cs.name }) is ChannelEntity {
// Bail out if the same channel name already exists
if myInfo.channels.first(where: { $0.name == cs.name }) != nil {
throw AccessoryError.appError("Channel already exists")
}
}
@ -619,9 +614,9 @@ extension AccessoryManager {
wayPointEntity.expire = nil
}
if waypoint.lockedTo > 0 {
wayPointEntity.locked = Int64(waypoint.lockedTo)
wayPointEntity.locked = true
} else {
wayPointEntity.locked = 0
wayPointEntity.locked = false
}
if wayPointEntity.created == nil {
wayPointEntity.created = Date()
@ -667,14 +662,11 @@ extension AccessoryManager {
let logString = String.localizedStringWithFormat("Sent a TraceRoute Packet from: %@ to: %@".localized, String(fromNodeNum), String(destNum))
try await send(toRadio, debugDescription: logString)
let traceRoute = TraceRouteEntity(context: context)
let nodes = NodeInfoEntity.fetchRequest()
let traceRoute = TraceRouteEntity()
context.insert(traceRoute)
traceRoute.sent = true
// TODO: Not sure what's going on here. We always have a fromNodeNum
// if let connectedNum = fromNodeNum {
nodes.predicate = NSPredicate(format: "num IN %@", [destNum, fromNodeNum])
// } else {
// nodes.predicate = NSPredicate(format: "num == %@", destNum)
// }
let nodes = FetchDescriptor<NodeInfoEntity>(predicate: #Predicate { $0.num == destNum || $0.num == fromNodeNum })
do {
let fetchedNodes = try context.fetch(nodes)
let receivingNode = fetchedNodes.first(where: { $0.num == destNum })

View file

@ -123,7 +123,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
// Chicken/Egg problem. Set in the App object immediately after
// AppState and AccessoryManager are created
var appState: AppState!
let context = PersistenceController.shared.container.viewContext
let context = PersistenceController.shared.context
let mqttManager = MqttClientProxyManager.shared
// Published Stuff
@ -497,6 +497,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
}
private func processFromRadio(_ decodedInfo: FromRadio) async {
Logger.transport.info("📻 [processFromRadio] Processing: \(String(describing: decodedInfo.payloadVariant), privacy: .public)")
switch decodedInfo.payloadVariant {
case .mqttClientProxyMessage(let mqttClientProxyMessage):
handleMqttClientProxyMessage(mqttClientProxyMessage)

View file

@ -6,6 +6,7 @@
//
import Foundation
import SwiftData
@preconcurrency import CoreBluetooth
import SwiftUI
import OSLog
@ -348,15 +349,19 @@ actor BLETransport: Transport {
// Get the device name
if let nodeNum {
let fetchMyInfoRequest = NodeInfoEntity.fetchRequest()
fetchMyInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum))
let nodeNumVal = Int64(nodeNum)
let descriptor = FetchDescriptor<NodeInfoEntity>(
predicate: #Predicate { $0.num == nodeNumVal }
)
do {
let fetchedMyInfo = try PersistenceController.shared.container.viewContext.fetch(fetchMyInfoRequest)
if fetchedMyInfo.count > 0 {
if let longName = fetchedMyInfo[0].user?.longName {
let container = PersistenceController.shared.container
let bgContext = ModelContext(container)
let fetchedNodes = try bgContext.fetch(descriptor)
if let first = fetchedNodes.first {
if let longName = first.user?.longName {
device.longName = longName
}
if let shortName = fetchedMyInfo[0].user?.shortName {
if let shortName = first.user?.shortName {
device.shortName = shortName
}
}

View file

@ -29,8 +29,9 @@ struct FactoryResetNodeIntent: AppIntent {
}
// Safely unwrap the connected node information
let context = await MainActor.run { PersistenceController.shared.context }
if let connectedPeripheralNum = await AccessoryManager.shared.activeDeviceNum,
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext),
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: context),
let fromUser = connectedNode.user,
let toUser = connectedNode.user {

View file

@ -8,7 +8,7 @@
import Foundation
import AppIntents
import CoreLocation
import CoreData
import SwiftData
import UIKit
@available(iOS 16.4, *)
@ -26,12 +26,15 @@ struct NavigateToNodeIntent: ForegroundContinuableIntent {
throw AppIntentErrors.AppIntentError.notConnected
}
let fetchNodeInfoRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "NodeInfoEntity")
fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum))
let nodeNumInt64 = Int64(nodeNum)
var descriptor = FetchDescriptor<NodeInfoEntity>(
predicate: #Predicate<NodeInfoEntity> { $0.num == nodeNumInt64 }
)
descriptor.fetchLimit = 1
do {
guard let fetchedNode = try PersistenceController.shared.container.viewContext.fetch(fetchNodeInfoRequest) as? [NodeInfoEntity],
fetchedNode.count == 1 else {
let fetchedNode = try await MainActor.run { try PersistenceController.shared.context.fetch(descriptor) }
guard fetchedNode.count == 1 else {
throw $nodeNum.needsValueError("Could not find node")
}

View file

@ -8,7 +8,7 @@
import Foundation
import AppIntents
import CoreLocation
import CoreData
import SwiftData
struct NodePositionIntent: AppIntent {
@ -22,16 +22,19 @@ struct NodePositionIntent: AppIntent {
if !(await AccessoryManager.shared.isConnected) {
throw AppIntentErrors.AppIntentError.notConnected
}
let fetchNodeInfoRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "NodeInfoEntity")
fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum))
let nodeNumInt64 = Int64(nodeNum)
var descriptor = FetchDescriptor<NodeInfoEntity>(
predicate: #Predicate<NodeInfoEntity> { $0.num == nodeNumInt64 }
)
descriptor.fetchLimit = 1
do {
guard let fetchedNode = try PersistenceController.shared.container.viewContext.fetch(fetchNodeInfoRequest) as? [NodeInfoEntity], fetchedNode.count == 1 else {
let fetchedNode = try await MainActor.run { try PersistenceController.shared.context.fetch(descriptor) }
guard fetchedNode.count == 1 else {
throw $nodeNum.needsValueError("Could not find node")
}
let nodeInfo = fetchedNode[0]
if let latitude = nodeInfo.latestPosition?.coordinate.latitude,
let longitude = nodeInfo.latestPosition?.coordinate.longitude {
let nodeLocation = CLLocation(latitude: latitude, longitude: longitude)
if let coord = nodeInfo.latestPosition?.nodeCoordinate {
let nodeLocation = CLLocation(latitude: coord.latitude, longitude: coord.longitude)
// Reverse geocode the CLLocation to get a CLPlacemark
let geocoder = CLGeocoder()
let placemarks = try await geocoder.reverseGeocodeLocation(nodeLocation)

View file

@ -19,8 +19,9 @@ struct RestartNodeIntent: AppIntent {
throw AppIntentErrors.AppIntentError.notConnected
}
// Safely unwrap the connectedNode using if let
let context = await MainActor.run { PersistenceController.shared.context }
if let connectedPeripheralNum = await AccessoryManager.shared.activeDeviceNum,
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext),
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: context),
let fromUser = connectedNode.user,
let toUser = connectedNode.user {

View file

@ -21,8 +21,9 @@ struct ShutDownNodeIntent: AppIntent {
}
// Safely unwrap the connectedNode using if let
let context = await MainActor.run { PersistenceController.shared.context }
if let connectedPeripheralNum = await AccessoryManager.shared.activeDeviceNum,
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext),
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: context),
let fromUser = connectedNode.user,
let toUser = connectedNode.user {

View file

@ -11,9 +11,9 @@
import CarPlay
import Combine
import CoreData
import Intents
import OSLog
import SwiftData
#if canImport(ActivityKit)
import ActivityKit
#endif
@ -29,7 +29,7 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
// during this CarPlay session so we don't re-donate on every refresh.
private var donatedConversationIds = Set<String>()
private lazy var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
private lazy var context: ModelContext = PersistenceController.shared.context
/// Returns a human-readable "last heard" string.
/// `now` is passed in so all rows in a single render share one `Date()` allocation.
@ -177,13 +177,12 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
// MARK: - Data Fetching
private func fetchFavoriteContactItems() -> [CPMessageListItem] {
let request: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
request.predicate = NSPredicate(format: "favorite == YES AND num != %lld", AccessoryManager.shared.activeDeviceNum ?? 0)
request.sortDescriptors = [NSSortDescriptor(key: "lastHeard", ascending: false)]
request.relationshipKeyPathsForPrefetching = ["user"]
do {
let nodes = try context.fetch(request)
let descriptor = FetchDescriptor<NodeInfoEntity>(
sortBy: [SortDescriptor(\.lastHeard, order: .reverse)]
)
let activeNum = AccessoryManager.shared.activeDeviceNum ?? 0
let nodes = try context.fetch(descriptor).filter { $0.favorite && $0.num != activeNum }
let nodeNums = nodes.compactMap { $0.user != nil ? $0.num : nil as Int64? }
let unreadCounts = fetchUnreadCountsForDMs(nodeNums: nodeNums)
let now = Date()
@ -225,12 +224,13 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
private func fetchChannelItems() -> [CPMessageListItem] {
guard let connectedNum = AccessoryManager.shared.activeDeviceNum,
let connectedNode = getNodeInfo(id: connectedNum, context: context),
let myInfo = connectedNode.myInfo,
let channels = myInfo.channels?.array as? [ChannelEntity] else {
let myInfo = connectedNode.myInfo else {
return []
}
let activeChannels = channels.filter { $0.role > 0 }
let activeChannels = myInfo.channels
.filter { $0.role > 0 }
.sorted { $0.index < $1.index }
let channelIndices = activeChannels.map { $0.index }
let unreadCounts = fetchUnreadCountsForChannels(channelIndices: channelIndices)
@ -267,35 +267,37 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
}
private func fetchDirectMessageItems() -> [CPMessageListItem] {
let request: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
let connectedNum = AccessoryManager.shared.activeDeviceNum ?? 0
// Match the app's UserList: exclude self, ignored, favorites (shown above).
// Use `lastMessage != nil` instead of the expensive `@count` aggregate predicate
// to find nodes that have exchanged at least one message.
let notSelf = NSPredicate(format: "userNode.num != %lld", connectedNum)
let notIgnored = NSPredicate(format: "userNode.ignored == NO")
let notFavorite = NSPredicate(format: "userNode.favorite == NO")
let hasMessagesOrMessagable = NSCompoundPredicate(type: .or, subpredicates: [
NSPredicate(format: "unmessagable == NO"),
NSPredicate(format: "lastMessage != nil")
])
request.predicate = NSCompoundPredicate(type: .and, subpredicates: [notSelf, notIgnored, notFavorite, hasMessagesOrMessagable])
request.sortDescriptors = [
NSSortDescriptor(key: "userNode.lastHeard", ascending: false),
NSSortDescriptor(key: "lastMessage", ascending: false),
NSSortDescriptor(key: "longName", ascending: true)
]
request.fetchLimit = 24 // CarPlay limits list items
request.relationshipKeyPathsForPrefetching = ["userNode"]
do {
let users = try context.fetch(request)
let users = try context.fetch(FetchDescriptor<UserEntity>())
let connectedNum = AccessoryManager.shared.activeDeviceNum ?? 0
let filteredUsers = users
.filter { user in
guard let node = user.userNode else { return false }
let notSelf = node.num != connectedNum
let notIgnored = !node.ignored
let notFavorite = !node.favorite
let hasMessagesOrMessagable = !user.unmessagable || user.lastMessage != nil
return notSelf && notIgnored && notFavorite && hasMessagesOrMessagable
}
.sorted { lhs, rhs in
let lhsHeard = lhs.userNode?.lastHeard ?? .distantPast
let rhsHeard = rhs.userNode?.lastHeard ?? .distantPast
if lhsHeard != rhsHeard {
return lhsHeard > rhsHeard
}
let lhsLastMessage = lhs.lastMessage ?? .distantPast
let rhsLastMessage = rhs.lastMessage ?? .distantPast
if lhsLastMessage != rhsLastMessage {
return lhsLastMessage > rhsLastMessage
}
return (lhs.longName ?? lhs.shortName ?? "") < (rhs.longName ?? rhs.shortName ?? "")
}
.prefix(24)
let nodeNums = users.compactMap { $0.userNode?.num }
let unreadCounts = fetchUnreadCountsForDMs(nodeNums: nodeNums)
let now = Date()
return users.compactMap { user -> CPMessageListItem? in
return filteredUsers.compactMap { user -> CPMessageListItem? in
guard let node = user.userNode else { return nil }
let name = user.longName ?? user.shortName ?? "Unknown"
let nodeNum = node.num
@ -337,18 +339,17 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
/// while staying compatible with Core Data's relationship keypath restrictions.
private func fetchUnreadCountsForDMs(nodeNums: [Int64]) -> [Int64: Int] {
guard !nodeNums.isEmpty else { return [:] }
let nodeNumSet = Set(nodeNums)
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "read == NO"),
NSPredicate(format: "fromUser.num IN %@", nodeNums)
])
fetchRequest.relationshipKeyPathsForPrefetching = ["fromUser"]
let results = (try? context.fetch(fetchRequest)) ?? []
let descriptor = FetchDescriptor<MessageEntity>(
predicate: #Predicate { message in
message.read == false
}
)
let results = (try? context.fetch(descriptor)) ?? []
var counts = [Int64: Int]()
for message in results {
if let num = message.fromUser?.num {
if let num = message.fromUser?.num, nodeNumSet.contains(num) {
counts[num, default: 0] += 1
}
}
@ -359,18 +360,19 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
/// then groups the results in-memory.
private func fetchUnreadCountsForChannels(channelIndices: [Int32]) -> [Int32: Int] {
guard !channelIndices.isEmpty else { return [:] }
let channelSet = Set(channelIndices)
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "read == NO"),
NSPredicate(format: "toUser == nil"),
NSPredicate(format: "channel IN %@", channelIndices)
])
let results = (try? context.fetch(fetchRequest)) ?? []
let descriptor = FetchDescriptor<MessageEntity>(
predicate: #Predicate { message in
message.read == false && message.toUser == nil
}
)
let results = (try? context.fetch(descriptor)) ?? []
var counts = [Int32: Int]()
for message in results {
counts[message.channel, default: 0] += 1
if channelSet.contains(message.channel) {
counts[message.channel, default: 0] += 1
}
}
return counts
}
@ -464,8 +466,8 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
let nodeShortName = connectedNode?.user?.shortName ?? "?"
// Fetch latest local stats telemetry
let localStats = connectedNode?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4"))
let mostRecent = localStats?.lastObject as? TelemetryEntity
let localStats = connectedNode?.telemetries.filter { $0.metricsType == 4 }
let mostRecent = localStats?.last
let timerSeconds = 900 // 15 minute local stats interval
let future = Date(timeIntervalSinceNow: Double(timerSeconds))

View file

@ -5,48 +5,50 @@
// Copyright(c) Garth Vander Houwen 11/7/22.
//
import Foundation
import CoreData
import SwiftData
import MeshtasticProtobufs
extension ChannelEntity {
var messagePredicate: NSPredicate {
return NSPredicate(format: "channel == %ld AND toUser == nil AND isEmoji == false", self.index)
}
var messageFetchRequest: NSFetchRequest<MessageEntity> {
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
fetchRequest.predicate = messagePredicate
return fetchRequest
}
@MainActor
var allPrivateMessages: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest
return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
let context = PersistenceController.shared.context
let channelIndex = self.index
var descriptor = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> { msg in
msg.channel == channelIndex && msg.toUser == nil && msg.isEmoji == false
},
sortBy: [SortDescriptor(\.messageTimestamp, order: .forward)]
)
return (try? context.fetch(descriptor)) ?? []
}
@MainActor
var mostRecentPrivateMessage: MessageEntity? {
// Most recent channel message (descending, limit 1)
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: false)]
fetchRequest.fetchLimit = 1
return (try? context.fetch(fetchRequest))?.first
let context = PersistenceController.shared.context
let channelIndex = self.index
var descriptor = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> { msg in
msg.channel == channelIndex && msg.toUser == nil && msg.isEmoji == false
},
sortBy: [SortDescriptor(\.messageTimestamp, order: .reverse)]
)
descriptor.fetchLimit = 1
return try? context.fetch(descriptor).first
}
func unreadMessages(context: NSManagedObjectContext) -> Int {
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [] // sort is irrelevant.
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
return (try? context.count(for: fetchRequest)) ?? 0
@MainActor
func unreadMessages(context: ModelContext) -> Int {
let channelIndex = self.index
let descriptor = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> { msg in
msg.channel == channelIndex && msg.toUser == nil && msg.isEmoji == false && msg.read == false
}
)
return (try? context.fetchCount(descriptor)) ?? 0
}
// Backwards-compatible property (uses viewContext)
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) }
@MainActor
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.context) }
var protoBuf: Channel {
var channel = Channel()

View file

@ -1,13 +1,10 @@
import Foundation
import CoreData
import SwiftData
import MeshtasticProtobufs
extension DeviceMetadataEntity {
convenience init(
context: NSManagedObjectContext,
metadata: DeviceMetadata
) {
self.init(context: context)
convenience init(metadata: DeviceMetadata) {
self.init()
self.time = Date()
self.deviceStateVersion = Int32(metadata.deviceStateVersion)
self.canShutdown = metadata.canShutdown

View file

@ -1,12 +1,9 @@
import CoreData
import SwiftData
import MeshtasticProtobufs
extension ExternalNotificationConfigEntity {
convenience init(
context: NSManagedObjectContext,
config: ModuleConfig.ExternalNotificationConfig
) {
self.init(context: context)
convenience init(config: ModuleConfig.ExternalNotificationConfig) {
self.init()
self.enabled = config.enabled
self.usePWM = config.usePwm
self.alertBell = config.alertBell

View file

@ -5,7 +5,7 @@
// Copyright (c) Garth Vander Houwen 11/21/23.
//
import CoreData
import SwiftData
import CoreLocation
import MapKit
import SwiftUI

View file

@ -1,12 +1,9 @@
import CoreData
import SwiftData
import MeshtasticProtobufs
extension MQTTConfigEntity {
convenience init(
context: NSManagedObjectContext,
config: ModuleConfig.MQTTConfig
) {
self.init(context: context)
convenience init(config: ModuleConfig.MQTTConfig) {
self.init()
self.enabled = config.enabled
self.proxyToClientEnabled = config.proxyToClientEnabled
self.address = config.address

View file

@ -1,63 +0,0 @@
//
// ManagedAttributePropertyWrapper.swift
// Meshtastic
//
// Created by Jake Bordens on 12/26/24.
//
import CoreData
@propertyWrapper
public struct ManagedAttribute<Value: Numeric> {
private let attributeName: String
private let converter: (NSNumber) -> Value?
public init(attributeName: String) {
self.attributeName = attributeName
// Define the converter closure based on the generic type Value
if Value.self == Float.self {
converter = { $0.floatValue as? Value }
} else if Value.self == Double.self {
converter = { $0.doubleValue as? Value }
} else if Value.self == Int.self {
converter = { $0.intValue as? Value }
} else if Value.self == Int8.self {
converter = { $0.int8Value as? Value }
} else if Value.self == Int16.self {
converter = { $0.int16Value as? Value }
} else if Value.self == Int32.self {
converter = { $0.int32Value as? Value }
} else if Value.self == Int64.self {
converter = { $0.int64Value as? Value }
} else if Value.self == UInt32.self {
converter = { $0.uint32Value as? Value }
} else {
fatalError("Unsupported type: \(Value.self)")
}
}
public var wrappedValue: Value? {
get { fatalError("Access via enclosing instance required.") }
set { fatalError("Access via enclosing instance required.") }
}
public static subscript<EnclosingSelf: NSManagedObject>(
_enclosingInstance observed: EnclosingSelf,
wrapped wrappedKeyPath: KeyPath<EnclosingSelf, Value?>,
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, ManagedAttribute<Value>>
) -> Value? {
get {
let wrapper = observed[keyPath: storageKeyPath]
let number = observed.primitiveValue(forKey: wrapper.attributeName) as? NSNumber
return number.flatMap { wrapper.converter($0) }
}
set {
let wrapper = observed[keyPath: storageKeyPath]
if let newValue = newValue {
observed.setPrimitiveValue(NSNumber(value: Double("\(newValue)")!), forKey: wrapper.attributeName)
} else {
observed.setPrimitiveValue(nil, forKey: wrapper.attributeName)
}
}
}
}

View file

@ -5,7 +5,7 @@
// Created by Ben on 8/22/23.
//
import CoreData
import SwiftData
import CoreLocation
import Foundation
import MapKit
@ -40,18 +40,17 @@ extension MessageEntity {
return re?.canRetry ?? false
}
@MainActor
var tapbacks: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "messageTimestamp", ascending: true)
]
fetchRequest.predicate = NSPredicate(
format: "replyID == %lld AND isEmoji == true",
self.messageId
let context = PersistenceController.shared.context
let msgId = self.messageId
let descriptor = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> { msg in
msg.replyID == msgId && msg.isEmoji == true
},
sortBy: [SortDescriptor(\MessageEntity.messageTimestamp, order: .forward)]
)
return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
return (try? context.fetch(descriptor)) ?? []
}
func displayTimestamp(aboveMessage: MessageEntity?) -> Bool {
@ -61,43 +60,38 @@ extension MessageEntity {
return false // First message will have no timestamp
}
@MainActor
func relayDisplay() -> String? {
guard self.relayNode != 0 else { return nil }
let context = PersistenceController.shared.container.viewContext
let context = PersistenceController.shared.context
let relaySuffix = Int64(self.relayNode & 0xFF)
let request: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
request.predicate = NSPredicate(
format: "(num & 0xFF) == %lld",
relaySuffix
)
let descriptor = FetchDescriptor<UserEntity>()
do {
let users = try context.fetch(request)
// If exactly one match is found, return its name
if users.count == 1, let name = users.first?.longName, !name.isEmpty {
return "\(name)"
}
// If no exact match, find the node with the smallest hopsAway
if let closestNode = users.min(by: { lhs, rhs in
guard let lhsHops = lhs.userNode?.hopsAway,
let rhsHops = rhs.userNode?.hopsAway
else {
return false
}
return lhsHops < rhsHops
}), let name = closestNode.longName, !name.isEmpty {
return "\(name)"
}
// Fallback to hex node number if no matches
return String(format: "Node 0x%02X", UInt32(self.relayNode & 0xFF))
} catch {
guard let users = try? context.fetch(descriptor) else {
return String(format: "Node 0x%02X", UInt32(self.relayNode & 0xFF))
}
let matchingUsers = users.filter { ($0.num & 0xFF) == relaySuffix }
// If exactly one match is found, return its name
if matchingUsers.count == 1, let name = matchingUsers.first?.longName, !name.isEmpty {
return "\(name)"
}
// If no exact match, find the node with the smallest hopsAway
if let closestNode = matchingUsers.min(by: { lhs, rhs in
guard let lhsHops = lhs.userNode?.hopsAway,
let rhsHops = rhs.userNode?.hopsAway
else {
return false
}
return lhsHops < rhsHops
}), let name = closestNode.longName, !name.isEmpty {
return "\(name)"
}
// Fallback to hex node number if no matches
return String(format: "Node 0x%02X", UInt32(self.relayNode & 0xFF))
}
}

View file

@ -6,41 +6,36 @@
//
import Foundation
import CoreData
import SwiftData
extension MyInfoEntity {
var messagePredicate: NSPredicate {
return NSPredicate(format: "toUser == nil AND isEmoji == false")
}
var messageFetchRequest: NSFetchRequest<MessageEntity> {
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
fetchRequest.predicate = messagePredicate
return fetchRequest
}
@MainActor
var messageList: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest
return (try? context.fetch(messageFetchRequest)) ?? [MessageEntity]()
let context = PersistenceController.shared.context
let descriptor = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> { msg in
msg.toUser == nil && msg.isEmoji == false
},
sortBy: [SortDescriptor(\MessageEntity.messageTimestamp, order: .forward)]
)
return (try? context.fetch(descriptor)) ?? []
}
func unreadMessages(context: NSManagedObjectContext) -> Int {
// Returns the count of unread *channel* messages
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [] // sort is irrelevant.
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
return (try? context.count(for: fetchRequest)) ?? 0
@MainActor
func unreadMessages(context: ModelContext) -> Int {
let descriptor = FetchDescriptor<MessageEntity>(
predicate: #Predicate<MessageEntity> { msg in
msg.toUser == nil && msg.isEmoji == false && msg.read == false
}
)
return (try? context.fetchCount(descriptor)) ?? 0
}
// Backwards-compatible property (uses viewContext)
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) }
@MainActor
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.context) }
var hasAdmin: Bool {
let adminChannel = channels?.filter { ($0 as AnyObject).name?.lowercased() == "admin" }
return adminChannel?.count ?? 0 > 0
let adminChannel = channels.filter { $0.name?.lowercased() == "admin" }
return adminChannel.count > 0
}
}

View file

@ -6,68 +6,75 @@
//
import Foundation
import CoreData
import SwiftData
extension NodeInfoEntity {
var latestPosition: PositionEntity? {
return self.positions?.lastObject as? PositionEntity
return self.positions.sorted(by: { ($0.time ?? .distantPast) < ($1.time ?? .distantPast) }).last
}
var latestDeviceMetrics: TelemetryEntity? {
return self.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).lastObject as? TelemetryEntity
return self.telemetries.filter { $0.metricsType == 0 }.sorted(by: { ($0.time ?? .distantPast) < ($1.time ?? .distantPast) }).last
}
var latestEnvironmentMetrics: TelemetryEntity? {
return self.telemetries?.filtered(using: NSPredicate(format: "metricsType == 1")).lastObject as? TelemetryEntity
return self.telemetries.filter { $0.metricsType == 1 }.sorted(by: { ($0.time ?? .distantPast) < ($1.time ?? .distantPast) }).last
}
var latestPowerMetrics: TelemetryEntity? {
return self.telemetries?.filtered(using: NSPredicate(format: "metricsType == 2")).lastObject as? TelemetryEntity
return self.telemetries.filter { $0.metricsType == 2 }.sorted(by: { ($0.time ?? .distantPast) < ($1.time ?? .distantPast) }).last
}
var hasPositions: Bool {
return self.positions?.count ?? 0 > 0
return self.positions.count > 0
}
var hasDeviceMetrics: Bool {
let deviceMetrics = telemetries?.filter { ($0 as AnyObject).metricsType == 0 }
return deviceMetrics?.count ?? 0 > 0
let deviceMetrics = telemetries.filter { $0.metricsType == 0 }
return deviceMetrics.count > 0
}
var hasEnvironmentMetrics: Bool {
let environmentMetrics = telemetries?.filter { ($0 as AnyObject).metricsType == 1 }
return environmentMetrics?.count ?? 0 > 0
let environmentMetrics = telemetries.filter { $0.metricsType == 1 }
return environmentMetrics.count > 0
}
func hasDataForLatestEnvironmentMetrics(attributes: [String]) -> Bool {
guard let latest = self.latestEnvironmentMetrics else { return false }
for attribute in attributes {
guard self.latestEnvironmentMetrics?.entity.attributesByName.keys.contains(attribute) ?? false else {
return false
}
if self.latestEnvironmentMetrics?.value(forKey: attribute) != nil {
return true
let mirror = Mirror(reflecting: latest)
if let child = mirror.children.first(where: { $0.label == attribute }) {
if child.value is Optional<Any> {
let m = Mirror(reflecting: child.value)
if m.displayStyle == .optional && m.children.count > 0 {
return true
}
} else {
return true
}
}
}
return false
}
@MainActor
var hasDetectionSensorMetrics: Bool {
return user?.sensorMessageList.count ?? 0 > 0
}
var hasPowerMetrics: Bool {
let powerMetrics = telemetries?.filter { ($0 as AnyObject).metricsType == 2 }
return powerMetrics?.count ?? 0 > 0
let powerMetrics = telemetries.filter { $0.metricsType == 2 }
return powerMetrics.count > 0
}
var hasTraceRoutes: Bool {
let routes = traceRoutes?.filter { ($0 as AnyObject).response }
return routes?.count ?? 0 > 0
let routes = traceRoutes.filter { $0.response }
return routes.count > 0
}
var hasPax: Bool {
return pax?.count ?? 0 > 0
return pax.count > 0
}
var isStoreForwardRouter: Bool {
@ -86,18 +93,18 @@ extension NodeInfoEntity {
if UserDefaults.enableAdministration {
return true
} else {
let adminChannel = myInfo?.channels?.filter { ($0 as AnyObject).name?.lowercased() == "admin" }
let adminChannel = myInfo?.channels.filter { $0.name?.lowercased() == "admin" }
return adminChannel?.count ?? 0 > 0
}
}
}
public func createNodeInfo(num: Int64, context: NSManagedObjectContext) -> NodeInfoEntity {
func createNodeInfo(num: Int64, context: ModelContext) -> NodeInfoEntity {
let newNode = NodeInfoEntity(context: context)
let newNode = NodeInfoEntity()
newNode.id = Int64(num)
newNode.num = Int64(num)
let newUser = UserEntity(context: context)
let newUser = UserEntity()
newUser.num = Int64(num)
let userId = num.toHex()
newUser.userId = "!\(userId)"
@ -106,5 +113,7 @@ public func createNodeInfo(num: Int64, context: NSManagedObjectContext) -> NodeI
newUser.shortName = last4
newUser.hwModel = "UNSET"
newNode.user = newUser
context.insert(newNode)
context.insert(newUser)
return newNode
}

View file

@ -5,7 +5,7 @@
// Copyright(c) Garth Vander Houwen 11/28/21.
//
import CoreData
import SwiftData
import CoreLocation
import MapKit
import MeshtasticProtobufs
@ -14,44 +14,14 @@ import SwiftUI
extension PositionEntity {
@MainActor
static func allPositionsFetchRequest() -> NSFetchRequest<PositionEntity> {
let request: NSFetchRequest<PositionEntity> = PositionEntity.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "time", ascending: false)]
let positionPredicate = NSPredicate(format: "nodePosition != nil AND nodePosition.user != nil AND latest == true AND nodePosition.user.shortName != ''")
request.predicate = positionPredicate
// Distance Predicate
if let cl = LocationsHandler.currentLocation {
let d: Double = UserDefaults.meshMapDistance * 1.1
let r: Double = 6371009 // Earth's mean radius in meters
// Calculate Bounding Box
let meanLatitidue = cl.latitude * .pi / 180
let deltaLatitude = d / r * 180 / .pi
let deltaLongitude = d / (r * cos(meanLatitidue)) * 180 / .pi
let minLatitude: Double = cl.latitude - deltaLatitude
let maxLatitude: Double = cl.latitude + deltaLatitude
let minLongitude: Double = cl.longitude - deltaLongitude
let maxLongitude: Double = cl.longitude + deltaLongitude
// Scale bounding box values by 1e7 and use integer attributes (longitudeI, latitudeI)
let scale: Double = 1e7
let minLongitudeI = Int(minLongitude * scale)
let maxLongitudeI = Int(maxLongitude * scale)
let minLatitudeI = Int(minLatitude * scale)
let maxLatitudeI = Int(maxLatitude * scale)
// Use integer comparison in the predicate
let distancePredicate = NSPredicate(format: "(%ld <= longitudeI) AND (longitudeI <= %ld) AND (%ld <= latitudeI) AND (latitudeI <= %ld)",
minLongitudeI, maxLongitudeI, minLatitudeI, maxLatitudeI)
request.predicate = NSCompoundPredicate(type: .and, subpredicates: [positionPredicate, distancePredicate])
}
return request
static func allPositionsFetchDescriptor() -> FetchDescriptor<PositionEntity> {
var descriptor = FetchDescriptor<PositionEntity>(
predicate: #Predicate<PositionEntity> { pos in
pos.nodePosition != nil && pos.latest == true
},
sortBy: [SortDescriptor(\.time, order: .reverse)]
)
return descriptor
}
var latitude: Double? {
@ -127,9 +97,19 @@ extension PositionEntity {
}
}
extension PositionEntity: MKAnnotation {
public var coordinate: CLLocationCoordinate2D { nodeCoordinate ?? LocationsHandler.DefaultLocation }
public var fuzzedCoordinate: CLLocationCoordinate2D { fuzzedNodeCoordinate ?? LocationsHandler.DefaultLocation }
public var title: String? { nodePosition?.user?.shortName ?? "Unknown".localized }
public var subtitle: String? { time?.formatted() }
class PositionAnnotation: NSObject, MKAnnotation {
let positionEntity: PositionEntity
@objc dynamic var coordinate: CLLocationCoordinate2D
var fuzzedCoordinate: CLLocationCoordinate2D
var title: String?
var subtitle: String?
init(position: PositionEntity) {
self.positionEntity = position
self.coordinate = position.nodeCoordinate ?? LocationsHandler.DefaultLocation
self.fuzzedCoordinate = position.fuzzedNodeCoordinate ?? LocationsHandler.DefaultLocation
self.title = position.nodePosition?.user?.shortName ?? "Unknown".localized
self.subtitle = position.time?.formatted()
super.init()
}
}

View file

@ -1,12 +1,9 @@
import CoreData
import SwiftData
import MeshtasticProtobufs
extension RangeTestConfigEntity {
convenience init(
context: NSManagedObjectContext,
config: ModuleConfig.RangeTestConfig
) {
self.init(context: context)
convenience init(config: ModuleConfig.RangeTestConfig) {
self.init()
self.sender = Int32(config.sender)
self.enabled = config.enabled
self.save = config.save

View file

@ -1,12 +1,9 @@
import CoreData
import SwiftData
import MeshtasticProtobufs
extension SerialConfigEntity {
convenience init(
context: NSManagedObjectContext,
config: ModuleConfig.SerialConfig
) {
self.init(context: context)
convenience init(config: ModuleConfig.SerialConfig) {
self.init()
self.enabled = config.enabled
self.echo = config.echo
self.rxd = Int32(config.rxd)

View file

@ -1,12 +1,9 @@
import CoreData
import SwiftData
import MeshtasticProtobufs
extension StoreForwardConfigEntity {
convenience init(
context: NSManagedObjectContext,
config: ModuleConfig.StoreForwardConfig
) {
self.init(context: context)
convenience init(config: ModuleConfig.StoreForwardConfig) {
self.init()
self.enabled = config.enabled
self.heartbeat = config.heartbeat
self.records = Int32(config.records)

View file

@ -5,7 +5,7 @@
// Copyright(c) Garth Vander Houwen 12/7/23.
//
import CoreData
import SwiftData
import CoreLocation
import MapKit
import SwiftUI

View file

@ -6,64 +6,39 @@
//
import Foundation
import CoreData
import SwiftData
import MeshtasticProtobufs
extension UserEntity {
var messagePredicate: NSPredicate {
return NSPredicate(format: "((toUser == %@) OR (fromUser == %@)) AND toUser != nil AND fromUser != nil AND isEmoji == false AND admin = false AND portNum != 10", self, self)
}
var messageFetchRequest: NSFetchRequest<MessageEntity> {
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
fetchRequest.predicate = messagePredicate
return fetchRequest
}
@MainActor
var messageList: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest
return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
let context = PersistenceController.shared.context
let messages = (self.sentMessages ?? []) + (self.receivedMessages ?? [])
return messages.filter { msg in
msg.toUser != nil && msg.fromUser != nil && !msg.isEmoji && !msg.admin && msg.portNum != 10
}.sorted { $0.messageTimestamp < $1.messageTimestamp }
}
@MainActor
var mostRecentMessage: MessageEntity? {
// Most contacts will have no DMs history, so we can return early.
guard self.lastMessage != nil else { return nil; }
// Most recent DM for this user (descending, limit 1)
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: false)]
fetchRequest.fetchLimit = 1
return (try? context.fetch(fetchRequest))?.first
guard self.lastMessage != nil else { return nil }
return messageList.last
}
@MainActor
var sensorMessageList: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
fetchRequest.predicate = NSPredicate(format: "(fromUser == %@) AND portNum = 10", self)
return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
return (self.sentMessages ?? []).filter { $0.portNum == 10 }
.sorted { $0.messageTimestamp < $1.messageTimestamp }
}
func unreadMessages(context: NSManagedObjectContext, skipLastMessageCheck: Bool = false) -> Int {
// Most contacts will have no DMs history, so we can return early.
// (For our own node, set skipLastMessageCheck=true, because we don't update lastMessage on our own connected node.)
guard self.lastMessage != nil || skipLastMessageCheck else { return 0; }
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [] // sort is irrelevant.
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
return (try? context.count(for: fetchRequest)) ?? 0
@MainActor
func unreadMessages(context: ModelContext, skipLastMessageCheck: Bool = false) -> Int {
guard self.lastMessage != nil || skipLastMessageCheck else { return 0 }
return messageList.filter { !$0.read }.count
}
// Backwards-compatible property (uses viewContext)
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) }
@MainActor
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.context) }
/// SVG Images for Vendors who are signed project backers
var hardwareImage: String? {
@ -159,26 +134,22 @@ extension UserEntity {
}
}
public func createUser(num: Int64, context: NSManagedObjectContext) throws -> UserEntity {
func createUser(num: Int64, context: ModelContext) throws -> UserEntity {
// Validate Input
guard num >= 0 else {
throw CoreDataError.invalidInput(message: "User number cannot be negative.")
}
var newUser: UserEntity! // Use an implicitly unwrapped optional, but ensure it's assigned
context.performAndWait {
newUser = UserEntity(context: context)
newUser.num = num
let userId = num.toHex()
newUser.userId = userId
let last4 = String(userId.suffix(4))
newUser.longName = "Meshtastic \(last4)"
newUser.shortName = last4
newUser.hwModel = "UNSET"
newUser.unmessagable = false
}
let newUser = UserEntity()
newUser.num = num
let userId = num.toHex()
newUser.userId = userId
let last4 = String(userId.suffix(4))
newUser.longName = "Meshtastic \(last4)"
newUser.shortName = last4
newUser.hwModel = "UNSET"
newUser.unmessagable = false
context.insert(newUser)
return newUser
}

View file

@ -4,20 +4,22 @@
//
// Copyright (c) Garth Vander Houwen 1/13/23.
//
import CoreData
@preconcurrency import SwiftData
import CoreLocation
import MapKit
import SwiftUI
extension WaypointEntity {
static func allWaypointssFetchRequest() -> NSFetchRequest<WaypointEntity> {
let request: NSFetchRequest<WaypointEntity> = WaypointEntity.fetchRequest()
request.fetchLimit = 50
request.returnsDistinctResults = true
request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: false)]
request.predicate = NSPredicate(format: "expire == nil || expire >= %@", Date() as NSDate)
return request
@MainActor
static func allWaypointsFetchDescriptor() -> FetchDescriptor<WaypointEntity> {
let now = Date()
return FetchDescriptor<WaypointEntity>(
predicate: #Predicate<WaypointEntity> { wp in
wp.expire == nil || wp.expire! >= now
},
sortBy: [SortDescriptor(\.name, order: .reverse)]
)
}
var latitude: Double? {
@ -54,26 +56,38 @@ extension WaypointEntity {
}
}
extension WaypointEntity: MKAnnotation {
extension WaypointEntity {
@MainActor
public var coordinate: CLLocationCoordinate2D {
var mapCoordinate: CLLocationCoordinate2D {
get {
waypointCoordinate ?? LocationsHandler.DefaultLocation
}
set {
latitudeI = Int32(newValue.latitude * 1e7)
longitudeI = Int32(newValue.longitude * 1e7)
}
}
public var title: String? {
var mapTitle: String? {
name ?? "Dropped Pin"
}
public var subtitle: String? {
var mapSubtitle: String? {
(longDescription ?? "") +
String(expire != nil ? "\n⌛ Expires \(String(describing: expire?.formatted()))" : "") +
String(locked > 0 ? "\n🔒 Locked" : "")
String(locked ? "\n🔒 Locked" : "")
}
}
class WaypointAnnotation: NSObject, MKAnnotation {
let waypointEntity: WaypointEntity
@objc dynamic var coordinate: CLLocationCoordinate2D
var title: String?
var subtitle: String?
@MainActor
init(waypoint: WaypointEntity) {
self.waypointEntity = waypoint
self.coordinate = waypoint.mapCoordinate
self.title = waypoint.mapTitle
self.subtitle = waypoint.mapSubtitle
super.init()
}
}

View file

@ -5,7 +5,7 @@
// Created by Benjamin Faershtein on 6/27/25.
//
import SwiftUI
import CoreData
import SwiftData
import OSLog
import TipKit
import MeshtasticProtobufs

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@
import Foundation
import MeshtasticProtobufs
import OSLog
import CoreData
import SwiftData
/// Bridges CoT messages between TAK clients and the Meshtastic mesh network
/// Handles bidirectional conversion and message routing
@ -18,8 +18,8 @@ final class TAKMeshtasticBridge {
weak var accessoryManager: AccessoryManager?
weak var takServerManager: TAKServerManager?
/// Core Data context for node lookups
var context: NSManagedObjectContext?
/// SwiftData context for node lookups
var context: ModelContext?
/// Lookup table mapping callsigns to device UIDs
/// Populated when receiving PLI packets from other TAK users
@ -519,7 +519,7 @@ final class TAKMeshtasticBridge {
guard let takServerManager, takServerManager.isRunning else { return }
// Get context - try the bridge's context first, then fall back to PersistenceController
let context = self.context ?? PersistenceController.shared.container.viewContext
let context = self.context ?? PersistenceController.shared.context
let twoHoursAgo = Date().addingTimeInterval(-7200)
@ -530,14 +530,12 @@ final class TAKMeshtasticBridge {
// Fetch all nodes - be more lenient, include any node that's been heard from
// We'll check positions when creating CoT messages
let fetchRequest: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "user != nil"
)
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "lastHeard", ascending: false)]
let descriptor = FetchDescriptor<NodeInfoEntity>()
do {
let nodes = try context.fetch(fetchRequest)
let nodes = try context.fetch(descriptor)
.filter { $0.user != nil }
.sorted { ($0.lastHeard ?? .distantPast) > ($1.lastHeard ?? .distantPast) }
Logger.tak.info("Found \(nodes.count) total nodes with user info, connected node: \(connectedNodeNum)")
var broadcastCount = 0
@ -594,15 +592,13 @@ final class TAKMeshtasticBridge {
private func lookupNodeInfo(nodeNum: UInt32) -> NodeInfoEntity? {
// Use PersistenceController's viewContext directly to ensure we can find nodes
let context = PersistenceController.shared.container.viewContext
let context = PersistenceController.shared.context
// Use the same format as MeshPackets - num is Int64
let fetchRequest: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum))
fetchRequest.fetchLimit = 1
let descriptor = FetchDescriptor<NodeInfoEntity>()
do {
return try context.fetch(fetchRequest).first
let nodeNumInt64 = Int64(nodeNum)
return try context.fetch(descriptor).first { $0.num == nodeNumInt64 }
} catch {
Logger.tak.warning("Failed to lookup node info for \(nodeNum): \(error.localizedDescription)")
return nil

View file

@ -10,7 +10,7 @@ import Network
import OSLog
import Combine
import SwiftUI
import CoreData
import SwiftData
import MeshtasticProtobufs
enum TAKServerError: LocalizedError {
@ -140,17 +140,16 @@ final class TAKServerManager: ObservableObject {
/// Check the primary channel for validity
/// Returns true if the primary channel is valid for TAK server operation
func checkPrimaryChannelValidity() {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = MyInfoEntity.fetchRequest()
let context = PersistenceController.shared.context
let descriptor = FetchDescriptor<MyInfoEntity>()
var issues: [PrimaryChannelIssue] = []
var isValid = true
do {
let myInfos = try context.fetch(fetchRequest)
let myInfos = try context.fetch(descriptor)
guard let myInfo = myInfos.first,
let channels = myInfo.channels?.array as? [ChannelEntity],
let primaryChannel = channels.first(where: { $0.index == 0 || $0.role == 1 }) else {
let primaryChannel = myInfo.channels.first(where: { $0.index == 0 || $0.role == 1 }) else {
issues.append(PrimaryChannelIssue(
title: "No Primary Channel",
description: "No primary channel found on device",
@ -584,7 +583,7 @@ final class TAKServerManager: ObservableObject {
Logger.tak.info("Auto-fixing primary channel for TAK compatibility")
let context = PersistenceController.shared.container.viewContext
let context = PersistenceController.shared.context
guard let connectedNodeNum = accessoryManager.activeDeviceNum else {
Logger.tak.error("Cannot fix channel: No active device number")
@ -597,13 +596,12 @@ final class TAKServerManager: ObservableObject {
return false
}
let fetchRequest = MyInfoEntity.fetchRequest()
let descriptor = FetchDescriptor<MyInfoEntity>()
do {
let myInfos = try context.fetch(fetchRequest)
let myInfos = try context.fetch(descriptor)
guard let myInfo = myInfos.first,
let channels = myInfo.channels?.array as? [ChannelEntity],
let primaryChannel = channels.first(where: { $0.index == 0 || $0.role == 1 }) else {
let primaryChannel = myInfo.channels.first(where: { $0.index == 0 || $0.role == 1 }) else {
Logger.tak.error("Cannot fix channel: No primary channel found")
return false
}
@ -619,12 +617,9 @@ final class TAKServerManager: ObservableObject {
primaryChannel.role = 1
primaryChannel.index = 0
if let mutableChannels = myInfo.channels?.mutableCopy() as? NSMutableOrderedSet {
if mutableChannels.contains(primaryChannel) {
mutableChannels.remove(primaryChannel)
mutableChannels.insert(primaryChannel, at: 0)
myInfo.channels = mutableChannels.copy() as? NSOrderedSet
}
if let idx = myInfo.channels.firstIndex(of: primaryChannel), idx != 0 {
myInfo.channels.remove(at: idx)
myInfo.channels.insert(primaryChannel, at: 0)
}
try context.save()

View file

@ -6,8 +6,8 @@
// used by the CarPlay messaging intent handlers.
//
import CoreData
import Intents
import SwiftData
enum IntentMessageConverters {
static let meshtasticDomain = "@meshtastic.local"
@ -58,40 +58,47 @@ enum IntentMessageConverters {
}
/// Searches for `UserEntity` objects whose name matches the given search term.
static func findUsers(matching searchTerm: String, in context: NSManagedObjectContext) -> [UserEntity] {
static func findUsers(matching searchTerm: String, in context: ModelContext) -> [UserEntity] {
if let nodeNum = directMessageNodeNum(from: searchTerm) {
let fetchRequest: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
fetchRequest.fetchLimit = 1
fetchRequest.predicate = NSPredicate(format: "num == %lld", nodeNum)
return (try? context.fetch(fetchRequest)) ?? []
let descriptor = FetchDescriptor<UserEntity>(
predicate: #Predicate<UserEntity> { user in
user.num == nodeNum
}
)
return (try? context.fetch(descriptor)) ?? []
}
let fetchRequest: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@ OR userId CONTAINS[cd] %@",
searchTerm, searchTerm, searchTerm
)
return (try? context.fetch(fetchRequest)) ?? []
let normalized = searchTerm.lowercased()
let users = (try? context.fetch(FetchDescriptor<UserEntity>())) ?? []
return users.filter { user in
(user.longName?.lowercased().contains(normalized) ?? false)
|| (user.shortName?.lowercased().contains(normalized) ?? false)
|| (user.userId?.lowercased().contains(normalized) ?? false)
}
}
/// Looks up a `ChannelEntity` by matching name.
static func findChannels(matching name: String, in context: NSManagedObjectContext) -> [ChannelEntity] {
static func findChannels(matching name: String, in context: ModelContext) -> [ChannelEntity] {
if let explicitIndex = channelIndex(fromHandleOrName: name) {
let fetchRequest: NSFetchRequest<ChannelEntity> = ChannelEntity.fetchRequest()
fetchRequest.fetchLimit = 1
fetchRequest.predicate = NSPredicate(format: "index == %d", explicitIndex)
return (try? context.fetch(fetchRequest)) ?? []
let explicitIndex32 = Int32(explicitIndex)
let descriptor = FetchDescriptor<ChannelEntity>(
predicate: #Predicate<ChannelEntity> { channel in
channel.index == explicitIndex32
}
)
return (try? context.fetch(descriptor)) ?? []
}
let fetchRequest: NSFetchRequest<ChannelEntity> = ChannelEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "name != nil AND name != '' AND name CONTAINS[cd] %@", name
)
return (try? context.fetch(fetchRequest)) ?? []
let normalized = name.lowercased()
let channels = (try? context.fetch(FetchDescriptor<ChannelEntity>())) ?? []
return channels.filter { channel in
guard let channelName = channel.name, !channelName.isEmpty else { return false }
return channelName.lowercased().contains(normalized)
}
}
/// Resolves a channel index from a spoken group name, defaulting to the primary channel.
static func channelIndex(for name: String, in context: NSManagedObjectContext) -> Int {
static func channelIndex(for name: String, in context: ModelContext) -> Int {
if let explicitIndex = channelIndex(fromHandleOrName: name) {
return explicitIndex
}

View file

@ -3,13 +3,13 @@
// Meshtastic
//
// Handles INSearchForMessagesIntent for CarPlay and Siri.
// Queries Core Data for messages matching the intent criteria
// Queries SwiftData for messages matching the intent criteria
// and returns them as INMessage objects.
//
import CoreData
import Intents
import OSLog
import SwiftData
final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentHandling {
@ -19,108 +19,85 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
// MARK: - Handling
func handle(intent: INSearchForMessagesIntent) async -> INSearchForMessagesIntentResponse {
// Use a private background context so the fetch does not block the main thread.
let bgContext = PersistenceController.shared.container.newBackgroundContext()
bgContext.automaticallyMergesChangesFromParent = true
let messages: [INMessage] = await MainActor.run {
let context = PersistenceController.shared.context
let descriptor = FetchDescriptor<MessageEntity>(
sortBy: [SortDescriptor(\.messageTimestamp, order: .reverse)]
)
let messages: [INMessage] = await bgContext.perform {
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
var predicates: [NSPredicate] = []
guard let fetched = try? context.fetch(descriptor) else {
Logger.services.error("CarPlay/Siri: Failed to search messages")
return []
}
// Exclude admin and emoji messages
predicates.append(NSPredicate(format: "admin == NO"))
predicates.append(NSPredicate(format: "isEmoji == NO"))
var results = fetched.filter { !$0.admin && !$0.isEmoji }
// Filter by conversation identifiers (e.g., "dm-123456" or "channel-0")
// This is the primary filter when Siri reads messages for a CarPlay contact.
if let conversationIds = intent.conversationIdentifiers, !conversationIds.isEmpty {
var conversationPredicates: [NSPredicate] = []
for convId in conversationIds {
if convId.hasPrefix("dm-"), let nodeNum = Int64(convId.dropFirst("dm-".count)) {
conversationPredicates.append(NSPredicate(format: "fromUser.num == %lld", nodeNum))
} else if convId.hasPrefix("channel-"), let channelIndex = Int32(convId.dropFirst("channel-".count)) {
conversationPredicates.append(NSPredicate(format: "channel == %d AND toUser == nil", channelIndex))
}
}
if !conversationPredicates.isEmpty {
predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: conversationPredicates))
let dmNums = Set(conversationIds.compactMap { convId -> Int64? in
guard convId.hasPrefix("dm-") else { return nil }
return Int64(convId.dropFirst("dm-".count))
})
let channelNums = Set(conversationIds.compactMap { convId -> Int32? in
guard convId.hasPrefix("channel-") else { return nil }
return Int32(convId.dropFirst("channel-".count))
})
results = results.filter { message in
let isDM = message.fromUser.map { dmNums.contains($0.num) } ?? false
let isChannel = message.toUser == nil && channelNums.contains(message.channel)
return isDM || isChannel
}
}
// Filter by identifiers (specific message IDs)
if let identifiers = intent.identifiers, !identifiers.isEmpty {
let messageIds = identifiers.compactMap { Int64($0) }
if !messageIds.isEmpty {
predicates.append(NSPredicate(format: "messageId IN %@", messageIds))
}
let messageIds = Set(identifiers.compactMap(Int64.init))
results = results.filter { messageIds.contains($0.messageId) }
}
// Filter by sender parse @meshtastic.local email-format handles
if let senders = intent.senders, !senders.isEmpty {
let senderNums = senders.compactMap { sender -> Int64? in
let senderNums = Set(senders.compactMap { sender -> Int64? in
guard let handleValue = sender.personHandle?.value else { return nil }
return IntentMessageConverters.directMessageNodeNum(from: handleValue)
}
if !senderNums.isEmpty {
predicates.append(NSPredicate(format: "fromUser.num IN %@", senderNums))
})
results = results.filter { message in
guard let senderNum = message.fromUser?.num else { return false }
return senderNums.contains(senderNum)
}
}
// Filter by date range.
// INDateComponentsRange exposes DateComponents on all platforms;
// .startDate/.endDate are iOS-only and unavailable on Mac Catalyst.
if let dateRange = intent.dateTimeRange {
let calendar = Calendar.current
if let startComponents = dateRange.startDateComponents,
let startDate = calendar.date(from: startComponents) {
let startTimestamp = Int32(startDate.timeIntervalSince1970)
predicates.append(NSPredicate(format: "messageTimestamp >= %d", startTimestamp))
}
if let endComponents = dateRange.endDateComponents,
let endDate = calendar.date(from: endComponents) {
let endTimestamp = Int32(endDate.timeIntervalSince1970)
predicates.append(NSPredicate(format: "messageTimestamp <= %d", endTimestamp))
let startTimestamp = dateRange.startDateComponents.flatMap { calendar.date(from: $0) }
.map { Int32($0.timeIntervalSince1970) }
let endTimestamp = dateRange.endDateComponents.flatMap { calendar.date(from: $0) }
.map { Int32($0.timeIntervalSince1970) }
results = results.filter { message in
if let startTimestamp, message.messageTimestamp < startTimestamp { return false }
if let endTimestamp, message.messageTimestamp > endTimestamp { return false }
return true
}
}
// Filter by group/channel name or handle
if let groupNames = intent.speakableGroupNames, !groupNames.isEmpty {
let channelIndices: [Int32] = groupNames.compactMap { groupName in
let channelIndices = Set(groupNames.compactMap { groupName -> Int32? in
if let idx = IntentMessageConverters.channelIndex(fromHandleOrName: groupName.spokenPhrase) {
return Int32(idx)
}
let channels = IntentMessageConverters.findChannels(
matching: groupName.spokenPhrase, in: bgContext
)
return channels.first.map { Int32($0.index) }
}
if !channelIndices.isEmpty {
predicates.append(NSPredicate(format: "channel IN %@ AND toUser == nil", channelIndices))
}
let channels = IntentMessageConverters.findChannels(matching: groupName.spokenPhrase, in: context)
return channels.first.map(\.index)
})
results = results.filter { $0.toUser == nil && channelIndices.contains($0.channel) }
}
// Filter by read/unread attribute
let attributes = intent.attributes
if attributes.contains(.read) {
predicates.append(NSPredicate(format: "read == YES"))
results = results.filter(\.read)
} else if attributes.contains(.unread) {
predicates.append(NSPredicate(format: "read == NO"))
results = results.filter { !$0.read }
}
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "messageTimestamp", ascending: false)
]
fetchRequest.fetchLimit = Self.maxResults
fetchRequest.relationshipKeyPathsForPrefetching = ["fromUser", "toUser"]
do {
let results = try bgContext.fetch(fetchRequest)
return results.map { IntentMessageConverters.inMessage(from: $0) }
} catch {
Logger.services.error("CarPlay/Siri: Failed to search messages: \(error.localizedDescription)")
return []
}
return Array(results.prefix(Self.maxResults)).map { IntentMessageConverters.inMessage(from: $0) }
}
let response = INSearchForMessagesIntentResponse(code: .success, userActivity: nil)

View file

@ -8,7 +8,6 @@
// Multiple recipients are not supported.
//
import CoreData
import Intents
import OSLog
@ -29,7 +28,6 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
return [.unsupported(forReason: .noAccount)]
}
let context = PersistenceController.shared.container.viewContext
let recipient = recipients[0]
let handleValue = recipient.personHandle?.value ?? ""
@ -43,10 +41,10 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
return [.success(with: recipient)]
}
// Otherwise search by display name
let searchTerm = recipient.displayName ?? handleValue
let searchTerm = recipient.displayName.isEmpty ? handleValue : recipient.displayName
let matchingUsers = await MainActor.run {
IntentMessageConverters.findUsers(matching: searchTerm, in: context)
let context = PersistenceController.shared.context
return IntentMessageConverters.findUsers(matching: searchTerm, in: context)
}
if matchingUsers.isEmpty {
@ -79,9 +77,9 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
return .needsValue()
}
let context = PersistenceController.shared.container.viewContext
let matchingChannels = await MainActor.run {
IntentMessageConverters.findChannels(matching: groupName.spokenPhrase, in: context)
let context = PersistenceController.shared.context
return IntentMessageConverters.findChannels(matching: groupName.spokenPhrase, in: context)
}
if matchingChannels.count == 1, let channel = matchingChannels.first {
@ -126,9 +124,9 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
do {
if let groupName = intent.speakableGroupName {
// Channel message
let context = PersistenceController.shared.container.viewContext
let channelIndex = await MainActor.run {
IntentMessageConverters.channelIndex(for: groupName.spokenPhrase, in: context)
let context = PersistenceController.shared.context
return IntentMessageConverters.channelIndex(for: groupName.spokenPhrase, in: context)
}
try await AccessoryManager.shared.sendMessage(
message: content,

View file

@ -3,12 +3,12 @@
// Meshtastic
//
// Handles INSetMessageAttributeIntent for CarPlay and Siri.
// Marks messages as read or unread in Core Data.
// Marks messages as read or unread in SwiftData.
//
import CoreData
import Intents
import OSLog
import SwiftData
final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeIntentHandling {
@ -39,20 +39,21 @@ final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeInt
}
let attribute = intent.attribute
// Use a private background context so Core Data work does not block the main thread.
let bgContext = PersistenceController.shared.container.newBackgroundContext()
bgContext.automaticallyMergesChangesFromParent = true
let messageIds = Set(identifiers.compactMap(Int64.init))
guard !messageIds.isEmpty else {
return INSetMessageAttributeIntentResponse(code: .failure, userActivity: nil)
}
let success: Bool = await bgContext.perform {
let messageIds = identifiers.compactMap { Int64($0) }
guard !messageIds.isEmpty else { return false }
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "messageId IN %@", messageIds)
let success = await MainActor.run { () -> Bool in
let context = PersistenceController.shared.context
let descriptor = FetchDescriptor<MessageEntity>()
do {
let messages = try bgContext.fetch(fetchRequest)
guard !messages.isEmpty else { return false }
let allMessages = try context.fetch(descriptor)
let messages = allMessages.filter { messageIds.contains($0.messageId) }
guard !messages.isEmpty else {
return false
}
for message in messages {
switch attribute {
@ -61,15 +62,14 @@ final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeInt
case .unread:
message.read = false
case .flagged, .unflagged:
// Meshtastic does not support message flagging
break
default:
break
}
}
if bgContext.hasChanges {
try bgContext.save()
if context.hasChanges {
try context.save()
}
Logger.services.info("CarPlay/Siri: Updated \(messages.count) message(s) to \(String(describing: attribute))")
return true

View file

@ -1,7 +1,7 @@
// Copyright (C) 2022 Garth Vander Houwen
import SwiftUI
import CoreData
import SwiftData
import OSLog
import TipKit
import MeshtasticProtobufs
@ -202,12 +202,12 @@ struct MeshtasticAppleApp: App {
SessionReplay.stopRecording()
accessoryManager.appDidEnterBackground()
do {
try persistenceController.container.viewContext.save()
Logger.services.info("💾 [App] Saved CoreData ViewContext when the app went to the background.")
try persistenceController.container.mainContext.save()
Logger.services.info("💾 [App] Saved SwiftData context when the app went to the background.")
} catch {
Logger.services.error("💥 [App] Failed to save viewContext when the app goes to the background.")
Logger.services.error("💥 [App] Failed to save context when the app goes to the background.")
}
case .inactive:
Logger.services.info("🎬 [App] Scene is inactive")
@ -220,7 +220,7 @@ struct MeshtasticAppleApp: App {
Logger.services.error("🍎 [App] Apple must have changed something")
}
}
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.modelContainer(persistenceController.container)
.environmentObject(appState)
.environmentObject(accessoryManager)
}

View file

@ -0,0 +1,26 @@
//
// ChannelEntity.swift
// Meshtastic
//
// SwiftData model for channels.
//
import Foundation
import SwiftData
@Model
final class ChannelEntity {
var downlinkEnabled: Bool = false
var id: Int32 = 0
var index: Int32 = 0
var mute: Bool = false
var name: String?
var positionPrecision: Int32 = 32
var psk: Data?
var role: Int32 = 0
var uplinkEnabled: Bool = false
var myInfoChannel: MyInfoEntity?
init() {}
}

View file

@ -0,0 +1,320 @@
//
// ConfigModels.swift
// Meshtastic
//
// SwiftData models for all device and module configuration entities.
//
import Foundation
import SwiftData
@Model
final class AmbientLightingConfigEntity {
var blue: Int32 = 0
var current: Int32 = 0
var green: Int32 = 0
var ledState: Bool = false
var red: Int32 = 0
var ambientLightingConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class BluetoothConfigEntity {
var deviceLoggingEnabled: Bool = false
var enabled: Bool = false
var fixedPin: Int32 = 123456
var mode: Int32 = 0
var bluetoothConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class CannedMessageConfigEntity {
var enabled: Bool = false
var inputbrokerEventCcw: Int32 = 0
var inputbrokerEventCw: Int32 = 0
var inputbrokerEventPress: Int32 = 0
var inputbrokerPinA: Int32 = 0
var inputbrokerPinB: Int32 = 0
var inputbrokerPinPress: Int32 = 0
var messages: String?
var rotary1Enabled: Bool = false
var sendBell: Bool = false
var updown1Enabled: Bool = false
var cannedMessagesConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class DetectionSensorConfigEntity {
var enabled: Bool = false
var minimumBroadcastSecs: Int32 = 0
var monitorPin: Int32 = 0
var name: String?
var sendBell: Bool = false
var stateBroadcastSecs: Int32 = 0
var triggerType: Int32 = 0
var usePullup: Bool = false
var detectionSensorConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class DeviceConfigEntity {
var buttonGpio: Int32 = 0
var buzzerGpio: Int32 = 0
var debugLogEnabled: Bool = false
var disableTripleClick: Bool = false
var doubleTapAsButtonPress: Bool = false
var isManaged: Bool = false
var ledHeartbeatEnabled: Bool = true
var nodeInfoBroadcastSecs: Int32 = 0
var rebroadcastMode: Int32 = 0
var role: Int32 = 0
var serialEnabled: Bool = false
var tripleClickAsAdHocPing: Bool = true
var tzdef: String?
var deviceConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class DisplayConfigEntity {
var compassNorthTop: Bool = false
var displayMode: Int32 = 0
var flipScreen: Bool = false
var headingBold: Bool = true
var oledType: Int32 = 0
var screenCarouselInterval: Int32 = 0
var screenOnSeconds: Int32 = 0
var units: Int32 = 0
var use12HClock: Bool = false
var wakeOnTapOrMotion: Bool = false
var displayConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class ExternalNotificationConfigEntity {
var active: Bool = false
var alertBell: Bool = false
var alertBellBuzzer: Bool = false
var alertBellVibra: Bool = false
var alertMessage: Bool = false
var alertMessageBuzzer: Bool = false
var alertMessageVibra: Bool = false
var enabled: Bool = false
var nagTimeout: Int32 = 0
var output: Int32 = 0
var outputBuzzer: Int32 = 0
var outputMilliseconds: Int32 = 0
var outputVibra: Int32 = 0
var useI2SAsBuzzer: Bool = false
var usePWM: Bool = true
var externalNotificationConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class LoRaConfigEntity {
var bandwidth: Int32 = 0
var channelNum: Int32 = 0
var codingRate: Int32 = 0
var frequencyOffset: Float = 0
var hopLimit: Int32 = 0
var ignoreMqtt: Bool = false
var modemPreset: Int32 = 0
var okToMqtt: Bool = false
var overrideDutyCycle: Bool = false
var overrideFrequency: Float = 0.0
var regionCode: Int32 = 0
var spreadFactor: Int32 = 0
var sx126xRxBoostedGain: Bool = false
var txEnabled: Bool = true
var txPower: Int32 = 0
var usePreset: Bool = true
var loRaConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class MQTTConfigEntity {
var address: String?
var enabled: Bool = false
var encryptionEnabled: Bool = false
var jsonEnabled: Bool = false
var mapPositionPrecision: Int32 = 13
var mapPublishIntervalSecs: Int32 = 0
var mapReportingEnabled: Bool = false
var mapReportingShouldReportLocation: Bool = false
var password: String?
var proxyToClientEnabled: Bool = false
var root: String? = "msh"
var tlsEnabled: Bool = false
var username: String?
var mqttConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class NetworkConfigEntity {
var dns: Int32 = 0
var enabledProtocols: Int32 = 0
var ethEnabled: Bool = false
var gateway: Int32 = 0
var ip: Int32 = 0
var ntpServer: String?
var subnet: Int32 = 0
var wifiEnabled: Bool = false
var wifiMode: Int32 = 0
var wifiPsk: String?
var wifiSsid: String?
var networkConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class PaxCounterConfigEntity {
var bleThreshold: Int32 = 0
var enabled: Bool = false
var updateInterval: Int32 = 0
var wifiThreshold: Int32 = -80
var paxCounterConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class PositionConfigEntity {
var broadcastSmartMinimumDistance: Int32 = 0
var broadcastSmartMinimumIntervalSecs: Int32 = 0
var deviceGpsEnabled: Bool = false
var fixedPosition: Bool = false
var gpsAttemptTime: Int32 = 0
var gpsEnGpio: Int32 = 0
var gpsMode: Int32 = 0
var gpsUpdateInterval: Int32 = 0
var positionBroadcastSeconds: Int32 = 0
var positionFlags: Int32 = 0
var rxGpio: Int32 = 0
var smartPositionEnabled: Bool = false
var txGpio: Int32 = 0
var positionConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class PowerConfigEntity {
var adcMultiplierOverride: Float = 0
var deviceBatteryInaAddress: Int32 = 0
var isPowerSaving: Bool = false
var lsSecs: Int32 = 0
var minWakeSecs: Int32 = 0
var onBatteryShutdownAfterSecs: Int32 = 0
var waitBluetoothSecs: Int32 = 0
var powerConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class RangeTestConfigEntity {
var enabled: Bool = false
var save: Bool = false
var sender: Int32 = 0
var rangeTestConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class RTTTLConfigEntity {
var ringtone: String?
var rtttlConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class SecurityConfigEntity {
var adminChannelEnabled: Bool = false
var adminKey: Data?
var adminKey2: Data?
var adminKey3: Data?
var bluetoothLoggingEnabled: Bool = false
var debugLogApiEnabled: Bool = false
var isManaged: Bool = false
var privateKey: Data?
var publicKey: Data?
var serialEnabled: Bool = false
var securityConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class SerialConfigEntity {
var baudRate: Int32 = 0
var echo: Bool = false
var enabled: Bool = false
var mode: Int32 = 0
var overrideConsoleSerialPort: Bool = false
var rxd: Int32 = 0
var timeout: Int32 = 0
var txd: Int32 = 0
var serialConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class StoreForwardConfigEntity {
var enabled: Bool = false
var heartbeat: Bool = false
var historyReturnMax: Int32 = 0
var historyReturnWindow: Int32 = 0
var isRouter: Bool = false
var lastHeartbeat: Date?
var lastRequest: Int32 = 0
var records: Int32 = 0
var storeForwardConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class TAKConfigEntity {
var role: Int32 = 0
var team: Int32 = 0
var takConfigNode: NodeInfoEntity?
init() {}
}
@Model
final class TelemetryConfigEntity {
var deviceTelemetryEnabled: Bool = false
var deviceUpdateInterval: Int32 = 0
var environmentDisplayFahrenheit: Bool = false
var environmentMeasurementEnabled: Bool = false
var environmentScreenEnabled: Bool = false
var environmentUpdateInterval: Int32 = 0
var powerMeasurementEnabled: Bool = false
var powerScreenEnabled: Bool = false
var powerUpdateInterval: Int32 = 0
var telemetryConfigNode: NodeInfoEntity?
init() {}
}

View file

@ -1,61 +0,0 @@
//
// TelemetryEntity+CoreDataClass.swift
//
//
// Created by Jake Bordens on 12/26/24.
//
//
import Foundation
import CoreData
// Manual implementation of the TelemetryEntry object for CoreData.
// Add optional scalar types here using the @ManagedAttribute property wrapper.
// CoreData is based on Objective-C, which doesn't have optional scalars.
// The @ManagedAttribute property wrapper handles the conversion to optional scalars.
@objc(TelemetryEntity)
public class TelemetryEntity: NSManagedObject, Identifiable {
@ManagedAttribute<Float>(attributeName: "airUtilTx") public var airUtilTx: Float?
@ManagedAttribute<Float>(attributeName: "barometricPressure") public var barometricPressure: Float?
@ManagedAttribute<Int32>(attributeName: "batteryLevel") public var batteryLevel: Int32?
@ManagedAttribute<Float>(attributeName: "channelUtilization") public var channelUtilization: Float?
@ManagedAttribute<Float>(attributeName: "current") public var current: Float?
@ManagedAttribute<Float>(attributeName: "distance") public var distance: Float?
@ManagedAttribute<Float>(attributeName: "gasResistance") public var gasResistance: Float?
@ManagedAttribute<Int32>(attributeName: "iaq") public var iaq: Int32?
@ManagedAttribute<Float>(attributeName: "powerCh1Current") var powerCh1Current: Float?
@ManagedAttribute<Float>(attributeName: "powerCh1Voltage") var powerCh1Voltage: Float?
@ManagedAttribute<Float>(attributeName: "powerCh2Current") var powerCh2Current: Float?
@ManagedAttribute<Float>(attributeName: "powerCh2Voltage") var powerCh2Voltage: Float?
@ManagedAttribute<Float>(attributeName: "powerCh3Current") var powerCh3Current: Float?
@ManagedAttribute<Float>(attributeName: "powerCh3Voltage") var powerCh3Voltage: Float?
@ManagedAttribute<Float>(attributeName: "relativeHumidity") public var relativeHumidity: Float?
@ManagedAttribute<Int32>(attributeName: "rssi") public var rssi: Int32?
@ManagedAttribute<Float>(attributeName: "snr") public var snr: Float?
@ManagedAttribute<Float>(attributeName: "temperature") public var temperature: Float?
@ManagedAttribute<Int32>(attributeName: "uptimeSeconds") public var uptimeSeconds: Int32?
@ManagedAttribute<Float>(attributeName: "voltage") public var voltage: Float?
@ManagedAttribute<Float>(attributeName: "weight") public var weight: Float?
@ManagedAttribute<Int32>(attributeName: "windDirection") public var windDirection: Int32?
@ManagedAttribute<Float>(attributeName: "windGust") public var windGust: Float?
@ManagedAttribute<Float>(attributeName: "windLull") public var windLull: Float?
@ManagedAttribute<Float>(attributeName: "windSpeed") public var windSpeed: Float?
@ManagedAttribute<Float>(attributeName: "irLux") public var irLux: Float?
@ManagedAttribute<Float>(attributeName: "lux") public var lux: Float?
@ManagedAttribute<Float>(attributeName: "uvLux") public var uvLux: Float?
@ManagedAttribute<Float>(attributeName: "whiteLux") public var whiteLux: Float?
@ManagedAttribute<Float>(attributeName: "radiation") public var radiation: Float?
@ManagedAttribute<Float>(attributeName: "rainfall1H") public var rainfall1H: Float?
@ManagedAttribute<Float>(attributeName: "rainfall24H") public var rainfall24H: Float?
@ManagedAttribute<Float>(attributeName: "soilTemperature") public var soilTemperature: Float?
@ManagedAttribute<UInt32>(attributeName: "soilMoisture") public var soilMoisture: UInt32?
public var dewPoint: Float? {
guard let temp = self.temperature, let rh = self.relativeHumidity else {
return nil
}
return Float(calculateDewPoint(temp: temp, relativeHumidity: rh, convertToLocale: false))
}
}

View file

@ -1,36 +0,0 @@
//
// TelemetryEntity+CoreDataProperties.swift
//
//
// Created by Jake Bordens on 12/26/24.
//
//
import Foundation
import CoreData
// Manual implementation of the TelemetryEntry object for CoreData.
// Add non-optional scalar types here using the standard @NSManaged proprty wrapper
// Add optional/non-optional object types here using the standard @NSManaged proprty wrapper
// CoreData is based on Objective-C which natively supports optionals for class types and
// non-optional scalars.
extension TelemetryEntity {
@nonobjc public class func fetchRequest() -> NSFetchRequest<TelemetryEntity> {
return NSFetchRequest<TelemetryEntity>(entityName: "TelemetryEntity")
}
@NSManaged public var time: Date?
@NSManaged public var metricsType: Int32
@NSManaged public var numOnlineNodes: Int32
@NSManaged public var numPacketsRx: Int32
@NSManaged public var numPacketsRxBad: Int32
@NSManaged public var numPacketsTx: Int32
@NSManaged public var numRxDupe: Int32
@NSManaged public var numTotalNodes: Int32
@NSManaged public var numTxRelay: Int32
@NSManaged public var numTxRelayCanceled: Int32
@NSManaged public var nodeTelemetry: NodeInfoEntity?
}

View file

@ -0,0 +1,28 @@
//
// DeviceMetadataEntity.swift
// Meshtastic
//
// SwiftData model for device metadata.
//
import Foundation
import SwiftData
@Model
final class DeviceMetadataEntity {
var canShutdown: Bool = false
var deviceStateVersion: Int32 = 0
var excludedModules: Int32 = 0
var firmwareVersion: String?
var hasBluetooth: Bool = false
var hasEthernet: Bool = false
var hasWifi: Bool = false
var hwModel: String?
var positionFlags: Int32 = 0
var role: Int32 = 0
var time: Date?
var metadataNode: NodeInfoEntity?
init() {}
}

View file

@ -0,0 +1,53 @@
//
// MeshtasticSchema.swift
// Meshtastic
//
// SwiftData schema definition and migration plan for migrating from Core Data.
//
import Foundation
import SwiftData
/// All model types in the Meshtastic schema
enum MeshtasticSchema {
static var allModels: [any PersistentModel.Type] {
[
// Core entities
NodeInfoEntity.self,
UserEntity.self,
MyInfoEntity.self,
MessageEntity.self,
ChannelEntity.self,
PositionEntity.self,
WaypointEntity.self,
DeviceMetadataEntity.self,
TelemetryEntity.self,
PaxCounterEntity.self,
TraceRouteEntity.self,
TraceRouteHopEntity.self,
RouteEntity.self,
LocationEntity.self,
// Config entities
AmbientLightingConfigEntity.self,
BluetoothConfigEntity.self,
CannedMessageConfigEntity.self,
DetectionSensorConfigEntity.self,
DeviceConfigEntity.self,
DisplayConfigEntity.self,
ExternalNotificationConfigEntity.self,
LoRaConfigEntity.self,
MQTTConfigEntity.self,
NetworkConfigEntity.self,
PaxCounterConfigEntity.self,
PositionConfigEntity.self,
PowerConfigEntity.self,
RangeTestConfigEntity.self,
RTTTLConfigEntity.self,
SecurityConfigEntity.self,
SerialConfigEntity.self,
StoreForwardConfigEntity.self,
TAKConfigEntity.self,
TelemetryConfigEntity.self,
]
}
}

View file

@ -0,0 +1,43 @@
//
// MessageEntity.swift
// Meshtastic
//
// SwiftData model for messages.
//
import Foundation
import SwiftData
@Model
final class MessageEntity {
var ackError: Int32 = 0
var ackSNR: Float = 0.0
var ackTimestamp: Int32 = 0
var admin: Bool = false
var adminDescription: String?
var channel: Int32 = 0
var isEmoji: Bool = false
var messageId: Int64 = 0
var messagePayload: String? = ""
var messagePayloadMarkdown: String?
var messagePayloadTranslated: String?
var messagePayloadTranslatedMarkdown: String?
var messageTimestamp: Int32 = 0
var pkiEncrypted: Bool = false
var portNum: Int32 = 0
var publicKey: Data?
var read: Bool = false
var realACK: Bool = false
var receivedACK: Bool = false
var relayNode: Int64 = 0
var relays: Int16 = 0
var replyID: Int64 = 0
var rssi: Int32 = 0
var showTranslatedMessage: Bool = false
var snr: Float = 0.0
var fromUser: UserEntity?
var toUser: UserEntity?
init() {}
}

View file

@ -48,7 +48,7 @@ class MetricsTableColumn: ObservableObject {
visible: Bool = true,
@ViewBuilder tableBody: @escaping (MetricsTableColumn, Value) -> TableContent?
) {
// This works because TelemetryEntity is an NSManagedObject and derrived from NSObject
// This works because TelemetryEntity is an @Model and conforms to PersistentModel
self.id = id
self.name = name
self.abbreviatedName = abbreviatedName

View file

@ -59,7 +59,7 @@ class MetricsChartSeries: ObservableObject {
@ChartContentBuilder chartBody: @escaping (MetricsChartSeries, ClosedRange<Float>?, Date, Value) -> ChartBody?
) {
// This works because TelemetryEntity is an NSManagedObject and derrived from NSObject
// This works because TelemetryEntity is an @Model and conforms to PersistentModel
self.id = id
self.name = name
self.abbreviatedName = abbreviatedName

View file

@ -0,0 +1,27 @@
//
// MyInfoEntity.swift
// Meshtastic
//
// SwiftData model for connected device info.
//
import Foundation
import SwiftData
@Model
final class MyInfoEntity {
var bleName: String?
var deviceId: Data?
var minAppVersion: Int32 = 0
var myNodeNum: Int64 = 0
var peripheralId: String?
var rebootCount: Int32 = 0
var registered: Bool = false
@Relationship(deleteRule: .cascade, inverse: \ChannelEntity.myInfoChannel)
var channels: [ChannelEntity] = []
var myInfoNode: NodeInfoEntity?
init() {}
}

View file

@ -0,0 +1,112 @@
//
// NodeInfoEntity.swift
// Meshtastic
//
// SwiftData model for the central node information entity.
//
import Foundation
import SwiftData
@Model
final class NodeInfoEntity {
var bleName: String?
var channel: Int32 = 0
var favorite: Bool = false
var firstHeard: Date?
var hopsAway: Int32 = 0
var id: Int64 = 0
var ignored: Bool = false
var lastHeard: Date?
var num: Int64 = 0
var peripheralId: String?
var rssi: Int32 = 0
var sessionExpiration: Date?
var sessionPasskey: Data?
var snr: Float = 0.0
var viaMqtt: Bool = false
// Config relationships (to-one, cascade)
@Relationship(deleteRule: .cascade, inverse: \AmbientLightingConfigEntity.ambientLightingConfigNode)
var ambientLightingConfig: AmbientLightingConfigEntity?
@Relationship(deleteRule: .cascade, inverse: \BluetoothConfigEntity.bluetoothConfigNode)
var bluetoothConfig: BluetoothConfigEntity?
@Relationship(deleteRule: .cascade, inverse: \CannedMessageConfigEntity.cannedMessagesConfigNode)
var cannedMessageConfig: CannedMessageConfigEntity?
@Relationship(deleteRule: .cascade, inverse: \DetectionSensorConfigEntity.detectionSensorConfigNode)
var detectionSensorConfig: DetectionSensorConfigEntity?
@Relationship(deleteRule: .cascade, inverse: \DeviceConfigEntity.deviceConfigNode)
var deviceConfig: DeviceConfigEntity?
@Relationship(deleteRule: .cascade, inverse: \DisplayConfigEntity.displayConfigNode)
var displayConfig: DisplayConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \ExternalNotificationConfigEntity.externalNotificationConfigNode)
var externalNotificationConfig: ExternalNotificationConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \LoRaConfigEntity.loRaConfigNode)
var loRaConfig: LoRaConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \DeviceMetadataEntity.metadataNode)
var metadata: DeviceMetadataEntity?
@Relationship(deleteRule: .nullify, inverse: \MQTTConfigEntity.mqttConfigNode)
var mqttConfig: MQTTConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \MyInfoEntity.myInfoNode)
var myInfo: MyInfoEntity?
@Relationship(deleteRule: .nullify, inverse: \NetworkConfigEntity.networkConfigNode)
var networkConfig: NetworkConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \PaxCounterEntity.paxNode)
var pax: [PaxCounterEntity] = []
@Relationship(deleteRule: .nullify, inverse: \PaxCounterConfigEntity.paxCounterConfigNode)
var paxCounterConfig: PaxCounterConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \PositionConfigEntity.positionConfigNode)
var positionConfig: PositionConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \PositionEntity.nodePosition)
var positions: [PositionEntity] = []
@Relationship(deleteRule: .nullify, inverse: \PowerConfigEntity.powerConfigNode)
var powerConfig: PowerConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \RangeTestConfigEntity.rangeTestConfigNode)
var rangeTestConfig: RangeTestConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \RTTTLConfigEntity.rtttlConfigNode)
var rtttlConfig: RTTTLConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \SecurityConfigEntity.securityConfigNode)
var securityConfig: SecurityConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \SerialConfigEntity.serialConfigNode)
var serialConfig: SerialConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \StoreForwardConfigEntity.storeForwardConfigNode)
var storeForwardConfig: StoreForwardConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \TAKConfigEntity.takConfigNode)
var takConfig: TAKConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \TelemetryEntity.nodeTelemetry)
var telemetries: [TelemetryEntity] = []
@Relationship(deleteRule: .nullify, inverse: \TelemetryConfigEntity.telemetryConfigNode)
var telemetryConfig: TelemetryConfigEntity?
@Relationship(deleteRule: .nullify, inverse: \TraceRouteEntity.node)
var traceRoutes: [TraceRouteEntity] = []
@Relationship(deleteRule: .nullify, inverse: \UserEntity.userNode)
var user: UserEntity?
init() {}
}

View file

@ -0,0 +1,29 @@
//
// PositionEntity.swift
// Meshtastic
//
// SwiftData model for node positions.
//
import Foundation
import SwiftData
@Model
final class PositionEntity {
var altitude: Int32 = 0
var heading: Int32 = 0
var latest: Bool = false
var latitudeI: Int32 = 0
var longitudeI: Int32 = 0
var precisionBits: Int32 = 32
var rssi: Int32 = 0
var satsInView: Int32 = 0
var seqNo: Int32 = 0
var snr: Float = 0.0
var speed: Int32 = 0
var time: Date?
var nodePosition: NodeInfoEntity?
init() {}
}

View file

@ -0,0 +1,53 @@
//
// RouteModels.swift
// Meshtastic
//
// SwiftData models for routes and locations.
//
import Foundation
import SwiftData
@Model
final class RouteEntity {
var color: Int64 = 0
var date: Date?
var distance: Double = 0
var elevationGain: Double = 0
var enabled: Bool = false
var endDate: Date?
var id: Int32 = 0
var name: String?
var notes: String?
@Relationship(deleteRule: .cascade, inverse: \LocationEntity.routeLocation)
var locations: [LocationEntity] = []
init() {}
}
@Model
final class LocationEntity {
var altitude: Int32 = 0
var heading: Int32 = 0
var id: Int32 = 0
var latitudeI: Int32 = 0
var longitudeI: Int32 = 0
var speed: Int32 = 0
var routeLocation: RouteEntity?
init() {}
}
@Model
final class PaxCounterEntity {
var ble: Int32 = 0
var time: Date?
var uptime: Int32 = 0
var wifi: Int32 = 0
var paxNode: NodeInfoEntity?
init() {}
}

View file

@ -0,0 +1,74 @@
//
// TelemetryEntity.swift
// Meshtastic
//
// SwiftData model for telemetry data.
// Replaces the manual Core Data TelemetryEntity+CoreDataClass/Properties files.
//
import Foundation
import SwiftData
@Model
final class TelemetryEntity {
// Non-optional scalars
var metricsType: Int32 = 0
var numOnlineNodes: Int32 = 0
var numPacketsRx: Int32 = 0
var numPacketsRxBad: Int32 = 0
var numPacketsTx: Int32 = 0
var numRxDupe: Int32 = 0
var numTotalNodes: Int32 = 0
var numTxRelay: Int32 = 0
var numTxRelayCanceled: Int32 = 0
var time: Date?
// Optional scalars (previously used @ManagedAttribute wrapper)
var airUtilTx: Float?
var barometricPressure: Float?
var batteryLevel: Int32?
var channelUtilization: Float?
var current: Float?
var distance: Float?
var gasResistance: Float?
var iaq: Int32?
var irLux: Float?
var lux: Float?
var powerCh1Current: Float?
var powerCh1Voltage: Float?
var powerCh2Current: Float?
var powerCh2Voltage: Float?
var powerCh3Current: Float?
var powerCh3Voltage: Float?
var radiation: Float?
var rainfall1H: Float?
var rainfall24H: Float?
var relativeHumidity: Float?
var rssi: Int32?
var snr: Float?
var soilMoisture: UInt32?
var soilTemperature: Float?
var temperature: Float?
var uptimeSeconds: Int32?
var uvLux: Float?
var voltage: Float?
var weight: Float?
var whiteLux: Float?
var windDirection: Int32?
var windGust: Float?
var windLull: Float?
var windSpeed: Float?
// Relationship
var nodeTelemetry: NodeInfoEntity?
// Computed property
var dewPoint: Float? {
guard let temp = self.temperature, let rh = self.relativeHumidity else {
return nil
}
return Float(calculateDewPoint(temp: temp, relativeHumidity: rh, convertToLocale: false))
}
init() {}
}

View file

@ -0,0 +1,46 @@
//
// TraceRouteModels.swift
// Meshtastic
//
// SwiftData models for trace routes and hops.
//
import Foundation
import SwiftData
@Model
final class TraceRouteEntity {
var hasPositions: Bool = false
var hopsBack: Int32 = 0
var hopsTowards: Int32 = 0
var id: Int64 = 0
var response: Bool = false
var routeBackText: String?
var routeText: String?
var sent: Bool = false
var snr: Float = 0.0
var time: Date?
@Relationship(deleteRule: .cascade, inverse: \TraceRouteHopEntity.traceRoute)
var hops: [TraceRouteHopEntity] = []
var node: NodeInfoEntity?
init() {}
}
@Model
final class TraceRouteHopEntity {
var altitude: Int32 = 0
var back: Bool = false
var latitudeI: Int32 = 0
var longitudeI: Int32 = 0
var name: String?
var num: Int64 = 0
var snr: Float = 0.0
var time: Date?
var traceRoute: TraceRouteEntity?
init() {}
}

View file

@ -0,0 +1,40 @@
//
// UserEntity.swift
// Meshtastic
//
// SwiftData model for user information.
//
import Foundation
import SwiftData
@Model
final class UserEntity {
var hwDisplayName: String?
var hwModel: String?
var hwModelId: Int32 = 0
var isLicensed: Bool = false
var keyMatch: Bool = true
var lastMessage: Date?
var longName: String?
var mute: Bool = false
var newPublicKey: Data?
var num: Int64 = 0
var numString: String?
var pkiEncrypted: Bool = false
var publicKey: Data?
var role: Int32 = 0
var shortName: String?
var unmessagable: Bool = false
var userId: String?
@Relationship(inverse: \MessageEntity.fromUser)
var sentMessages: [MessageEntity] = []
@Relationship(inverse: \MessageEntity.toUser)
var receivedMessages: [MessageEntity] = []
var userNode: NodeInfoEntity?
init() {}
}

View file

@ -0,0 +1,27 @@
//
// WaypointEntity.swift
// Meshtastic
//
// SwiftData model for waypoints.
//
import Foundation
import SwiftData
@Model
final class WaypointEntity {
var created: Date?
var createdBy: Int64 = 0
var expire: Date?
var icon: Int64 = 0
var id: Int64 = 0
var lastUpdated: Date?
var lastUpdatedBy: Int64 = 0
var latitudeI: Int32 = 0
var locked: Bool = false
var longDescription: String?
var longitudeI: Int32 = 0
var name: String?
init() {}
}

View file

@ -5,103 +5,75 @@
// Copyright(c) Garth Vander Houwen 11/28/21.
//
import CoreData
import SwiftData
import OSLog
@MainActor
class PersistenceController {
static let shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: false)
let viewContext = result.container.viewContext
let result = PersistenceController(inMemory: true)
let context = result.container.mainContext
for _ in 0..<10 {
let newItem = NodeInfoEntity(context: viewContext)
let newItem = NodeInfoEntity()
newItem.lastHeard = Date()
}
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
context.insert(newItem)
}
return result
}()
let container: NSPersistentContainer
let container: ModelContainer
var context: ModelContext {
container.mainContext
}
init(inMemory: Bool = false) {
let schema = Schema(MeshtasticSchema.allModels)
container = NSPersistentContainer(name: "Meshtastic")
let config = ModelConfiguration(
"Meshtastic",
schema: schema,
isStoredInMemoryOnly: inMemory,
allowsSave: true
)
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (_, error) in
// Merge policy that favors in memory data over data in the db
self.container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
self.container.viewContext.automaticallyMergesChangesFromParent = true
self.container.viewContext.shouldDeleteInaccessibleFaults = true
if let error = error as NSError? {
Logger.data.error("CoreData Error: \(error.localizedDescription, privacy: .public). Now attempting to truncate CoreData database. All app data will be lost.")
self.clearDatabase()
do {
container = try ModelContainer(for: schema, configurations: config)
container.mainContext.autosaveEnabled = true
Logger.data.info("💾 SwiftData store initialized successfully")
} catch {
Logger.data.error("SwiftData Error: \(error.localizedDescription, privacy: .public). Attempting to recreate database.")
// Attempt recovery by creating in-memory store
let fallbackConfig = ModelConfiguration(
"Meshtastic",
schema: schema,
isStoredInMemoryOnly: true
)
do {
container = try ModelContainer(for: schema, configurations: fallbackConfig)
Logger.data.error("SwiftData database recreated in-memory. All app data has been lost.")
} catch {
fatalError("Failed to create ModelContainer: \(error.localizedDescription)")
}
})
}
}
public func clearDatabase() {
guard let url = self.container.persistentStoreDescriptions.first?.url else { return }
let persistentStoreCoordinator = self.container.persistentStoreCoordinator
do {
try persistentStoreCoordinator.destroyPersistentStore(at: url, ofType: NSSQLiteStoreType, options: nil)
Logger.data.error("CoreData database truncated. All app data has been erased.")
do {
try persistentStoreCoordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: url, options: nil)
} catch let error {
Logger.data.error("Failed to re-create CoreData database: \(error.localizedDescription, privacy: .public)")
}
} catch let error {
Logger.data.error("Failed to destroy CoreData database, delete the app and re-install to clear data. Attempted to clear persistent store: \(error.localizedDescription, privacy: .public)")
@MainActor
public func clearDatabase(includeRoutes: Bool = true) {
do {
for modelType in MeshtasticSchema.allModels {
if !includeRoutes && (modelType == RouteEntity.self || modelType == LocationEntity.self) {
continue
}
try container.mainContext.delete(model: modelType)
}
try container.mainContext.save()
Logger.data.error("SwiftData database truncated. All app data has been erased.")
} catch {
Logger.data.error("Failed to clear SwiftData database: \(error.localizedDescription, privacy: .public)")
}
}
}
extension NSManagedObjectContext {
/// Executes the given `NSBatchDeleteRequest` and directly merges the changes to bring the given managed object context up to date.
///
/// - Parameter batchDeleteRequest: The `NSBatchDeleteRequest` to execute.
/// - Throws: An error if anything went wrong executing the batch deletion.
public func executeAndMergeChanges(using batchDeleteRequest: NSBatchDeleteRequest) throws {
batchDeleteRequest.resultType = .resultTypeObjectIDs
let result = try execute(batchDeleteRequest) as? NSBatchDeleteResult
let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self])
}
}
// Created by Tom Harrington on 5/12/20.
// Copyright © 2020 Atomic Bird LLC. All rights reserved.
// Gist from https://atomicbird.com/blog/core-data-back-up-store/
//
extension NSPersistentContainer {
enum CopyPersistentStoreErrors: Error {
case invalidDestination(String)
case destinationError(String)
case destinationNotRemoved(String)
case copyStoreError(String)
case invalidSource(String)
}
}

View file

@ -5,88 +5,62 @@
// Created(c) Garth Vander Houwen 1/16/23.
//
import CoreData
import Foundation
import SwiftData
public func getNodeInfo(id: Int64, context: NSManagedObjectContext) -> NodeInfoEntity? {
let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest()
fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(id))
fetchNodeInfoRequest.fetchLimit = 1
do {
let fetchedNode = try context.fetch(fetchNodeInfoRequest)
if fetchedNode.count == 1 {
return fetchedNode[0]
}
} catch {
return nil
}
return nil
func getNodeInfo(id: Int64, context: ModelContext) -> NodeInfoEntity? {
let num = id
var descriptor = FetchDescriptor<NodeInfoEntity>(
predicate: #Predicate { $0.num == num }
)
descriptor.fetchLimit = 1
return try? context.fetch(descriptor).first
}
public func getStoreAndForwardMessageIds(seconds: Int, context: NSManagedObjectContext) -> [UInt32] {
func getStoreAndForwardMessageIds(seconds: Int, context: ModelContext) -> [UInt32] {
let time = seconds * -1
let fetchMessagesRequest = MessageEntity.fetchRequest()
let timeRange = Calendar.current.date(byAdding: .minute, value: time, to: Date())
let milleseconds = Int32(timeRange?.timeIntervalSince1970 ?? 0)
fetchMessagesRequest.predicate = NSPredicate(format: "messageTimestamp >= %d", milleseconds)
do {
let fetchedMessages = try context.fetch(fetchMessagesRequest)
if fetchedMessages.count == 1 {
return fetchedMessages.map { UInt32($0.messageId) }
}
} catch {
return []
}
return []
let descriptor = FetchDescriptor<MessageEntity>(
predicate: #Predicate { $0.messageTimestamp >= milleseconds }
)
let fetchedMessages = (try? context.fetch(descriptor)) ?? []
return fetchedMessages.map { UInt32($0.messageId) }
}
public func getTraceRoute(id: Int64, context: NSManagedObjectContext) -> TraceRouteEntity? {
let fetchTraceRouteRequest = TraceRouteEntity.fetchRequest()
fetchTraceRouteRequest.predicate = NSPredicate(format: "id == %lld", Int64(id))
do {
let fetchedTraceRoute = try context.fetch(fetchTraceRouteRequest)
if fetchedTraceRoute.count == 1 {
return fetchedTraceRoute[0]
}
} catch {
return nil
}
return nil
func getTraceRoute(id: Int64, context: ModelContext) -> TraceRouteEntity? {
let traceId = id
var descriptor = FetchDescriptor<TraceRouteEntity>(
predicate: #Predicate { $0.id == traceId }
)
descriptor.fetchLimit = 1
return try? context.fetch(descriptor).first
}
public func getUser(id: Int64, context: NSManagedObjectContext) -> UserEntity {
let fetchUserRequest = UserEntity.fetchRequest()
fetchUserRequest.predicate = NSPredicate(format: "num == %lld", Int64(id))
do {
let fetchedUser = try context.fetch(fetchUserRequest)
if fetchedUser.count == 1 {
return fetchedUser[0]
}
} catch {
return UserEntity(context: context)
func getUser(id: Int64, context: ModelContext) -> UserEntity {
let userNum = id
let descriptor = FetchDescriptor<UserEntity>(
predicate: #Predicate { $0.num == userNum }
)
if let existing = try? context.fetch(descriptor).first {
return existing
}
return UserEntity(context: context)
let newUser = UserEntity()
newUser.num = id
context.insert(newUser)
return newUser
}
public func getWaypoint(id: Int64, context: NSManagedObjectContext) -> WaypointEntity {
let fetchWaypointRequest = WaypointEntity.fetchRequest()
fetchWaypointRequest.predicate = NSPredicate(format: "id == %lld", Int64(id))
do {
let fetchedWaypoint = try context.fetch(fetchWaypointRequest)
if fetchedWaypoint.count == 1 {
return fetchedWaypoint[0]
}
} catch {
return WaypointEntity(context: context)
func getWaypoint(id: Int64, context: ModelContext) -> WaypointEntity {
let waypointId = id
let descriptor = FetchDescriptor<WaypointEntity>(
predicate: #Predicate { $0.id == waypointId }
)
if let existing = try? context.fetch(descriptor).first {
return existing
}
return WaypointEntity(context: context)
let newWaypoint = WaypointEntity()
newWaypoint.id = id
context.insert(newWaypoint)
return newWaypoint
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import Combine
import CoreData
import SwiftData
import OSLog
import SwiftUI
@ -44,22 +44,22 @@ class Router: ObservableObject {
// MARK: Node Object ID Cache
/// In-memory cache mapping node numbers to their Core Data `NSManagedObjectID` for O(1) lookups.
/// In-memory cache mapping node numbers to their SwiftData `PersistentIdentifier` for O(1) lookups.
/// Thread-safe by virtue of Router's @MainActor isolation all access is on the main thread.
private var nodeObjectIDCache: [Int64: NSManagedObjectID] = [:]
private var nodeObjectIDCache: [Int64: PersistentIdentifier] = [:]
/// Updates the node cache from a set of fetched nodes. Call this when the node list changes.
func updateNodeIndex<C: Collection>(from nodes: C) where C.Element: NodeInfoEntity {
nodeObjectIDCache = Dictionary(
nodes.map { ($0.num, $0.objectID) },
nodes.map { ($0.num, $0.persistentModelID) },
uniquingKeysWith: { _, new in new }
)
}
/// Looks up a node using the in-memory cache for O(1) performance, falling back to a Core Data fetch.
func cachedNodeInfo(id: Int64, context: NSManagedObjectContext) -> NodeInfoEntity? {
if let objectID = nodeObjectIDCache[id] {
if let node = try? context.existingObject(with: objectID) as? NodeInfoEntity {
/// Looks up a node using the in-memory cache for O(1) performance, falling back to a SwiftData fetch.
func cachedNodeInfo(id: Int64, context: ModelContext) -> NodeInfoEntity? {
if let persistentID = nodeObjectIDCache[id] {
if let node = context.model(for: persistentID) as? NodeInfoEntity {
return node
}
// Stale entry (object deleted or faulted) evict and fall back to a fresh fetch
@ -68,7 +68,7 @@ class Router: ObservableObject {
// Cache miss fall back to standard fetch
let node = getNodeInfo(id: id, context: context)
if let node {
nodeObjectIDCache[id] = node.objectID
nodeObjectIDCache[id] = node.persistentModelID
}
return node
}

View file

@ -7,7 +7,7 @@
import SwiftUI
import MapKit
import CoreData
import SwiftData
import CoreLocation
import CoreBluetooth
import OSLog
@ -18,7 +18,7 @@ import ActivityKit
struct Connect: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.colorScheme) private var colorScheme
@State var router: Router
@ -364,8 +364,10 @@ struct Connect: View {
if let deviceNum = accessoryManager.activeDeviceNum, UserDefaults.preferredPeripheralId.count > 0 && state == .subscribed {
let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest()
fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", deviceNum)
var fetchNodeInfoRequest = FetchDescriptor<NodeInfoEntity>(
predicate: #Predicate<NodeInfoEntity> { $0.num == deviceNum }
)
fetchNodeInfoRequest.fetchLimit = 1
do {
node = try context.fetch(fetchNodeInfoRequest).first
@ -393,8 +395,8 @@ struct Connect: View {
liveActivityStarted = true
// 15 Minutes Local Stats Interval
let timerSeconds = 900
let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4"))
let mostRecent = localStats?.lastObject as? TelemetryEntity
let localStats = node?.telemetries.filter { $0.metricsType == 4 }
let mostRecent = localStats?.last
let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown", shortName: node?.user?.shortName ?? "?")
@ -465,7 +467,7 @@ struct TransportIcon: View {
struct ManualConnectionMenu: View {
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
private struct IterableTransport: Identifiable {
let id: UUID
@ -538,7 +540,7 @@ struct ManualConnectionMenu: View {
if accessoryManager.allowDisconnect {
try await accessoryManager.disconnect()
}
await MeshPackets.shared.clearCoreDataDatabase(includeRoutes: false)
await PersistenceController.shared.clearDatabase(includeRoutes: false)
clearNotifications()
try await selectedTransport?.transport.manuallyConnect(toDevice: device)
@ -552,7 +554,7 @@ struct ManualConnectionMenu: View {
}
struct DeviceConnectRow: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@State var presentingSwitchPreferredPeripheral = false
let device: Device
@ -619,7 +621,7 @@ struct DeviceConnectRow: View {
if accessoryManager.allowDisconnect {
try await accessoryManager.disconnect()
}
await MeshPackets.shared.clearCoreDataDatabase(includeRoutes: false)
await PersistenceController.shared.clearDatabase(includeRoutes: false)
clearNotifications()
try await accessoryManager.connect(to: device)

View file

@ -10,14 +10,14 @@ import Charts
struct BatteryGauge: View {
@ObservedObject var node: NodeInfoEntity
@Bindable var node: NodeInfoEntity
private let minValue = 0.0
private let maxValue = 100.00
var body: some View {
let deviceMetrics = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0"))
let mostRecent = deviceMetrics?.lastObject as? TelemetryEntity
let deviceMetrics = node.telemetries.filter { $0.metricsType == 0 }
let mostRecent = deviceMetrics.last
// For VoiceOver purposes, detect when device is plugged in (battery > 100%)
let isPluggedIn = (mostRecent?.batteryLevel ?? 0) > 100
// Use a capped battery level for UI display

View file

@ -8,7 +8,7 @@ import SwiftUI
struct ChannelLock: View {
@ObservedObject var channel: ChannelEntity
@Bindable var channel: ChannelEntity
var body: some View {
/// Unencrypted - using no key at all or a known 1 byte key
if channel.psk?.hexDescription.count ?? 0 < 3 {
@ -33,14 +33,16 @@ struct ChannelLock: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let encryptedChannel = ChannelEntity(context: context)
let encryptedChannel = ChannelEntity()
encryptedChannel.psk = Data([0x01, 0x02, 0x03, 0x04])
let unencryptedChannel = ChannelEntity(context: context)
let unencryptedChannel = ChannelEntity()
unencryptedChannel.psk = Data()
return HStack(spacing: 16) {
HStack(spacing: 16) {
ChannelLock(channel: encryptedChannel)
ChannelLock(channel: unencryptedChannel)
}
}
*/

View file

@ -4,7 +4,7 @@ A view draws a circle in the background of the shortName text
*/
import SwiftUI
import CoreData
import SwiftData
struct CircleText: View {
var text: String

View file

@ -38,13 +38,12 @@ struct MessageTemplate: View {
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let user = UserEntity(context: context)
let user = UserEntity()
user.longName = "Test User"
user.shortName = "TU"
let message = MessageEntity(context: context)
let message = MessageEntity()
message.messagePayload = "Hello, World!"
message.messageTimestamp = Int32(Date().timeIntervalSince1970)
message.replyID = 0
return MessageTemplate(user: user, message: message)
MessageTemplate(user: user, message: message)
}

View file

@ -6,12 +6,12 @@
//
import SwiftUI
import CoreData
import SwiftData
import OSLog
struct ChannelList: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@Binding var node: NodeInfoEntity?
@Binding var channelSelection: ChannelEntity?
@ -22,11 +22,19 @@ struct ChannelList: View {
var restrictedChannels = ["gpio", "mqtt", "serial", "admin"]
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \ChannelEntity.index, ascending: true)],
predicate: nil,
animation: .default
) private var channels: FetchedResults<ChannelEntity>
private var visibleChannels: [ChannelEntity] {
guard let myInfo = node?.myInfo else { return [] }
var channelsByIndex: [Int32: ChannelEntity] = [:]
for channel in myInfo.channels {
channelsByIndex[channel.index] = channel
}
return channelsByIndex
.values
.filter { !restrictedChannels.contains($0.name?.lowercased() ?? "") }
.sorted { $0.index < $1.index }
}
@ViewBuilder
private func makeChannelRow(
@ -108,73 +116,73 @@ struct ChannelList: View {
}
}
}
@ViewBuilder
private func makeChannelListItem(
node: NodeInfoEntity,
myInfo: MyInfoEntity,
channel: ChannelEntity
) -> some View {
let hasMessages = channel.mostRecentPrivateMessage != nil
makeChannelRow(myInfo: myInfo, channel: channel)
.alignmentGuide(.listRowSeparatorLeading) {
$0[.leading]
}
.frame(height: 62)
.contextMenu {
if hasMessages {
Button(role: .destructive) {
isPresentingDeleteChannelMessagesConfirm = true
channelToDeleteMessages = channel
} label: {
Label("Delete Messages", systemImage: "trash")
}
}
Button {
channel.mute.toggle()
Task {
do {
_ = try await accessoryManager.saveChannel(channel: channel.protoBuf, fromUser: node.user!, toUser: node.user!)
Task { @MainActor in
do {
try context.save()
} catch {
context.rollback()
Logger.data.error("💥 Save Channel Mute Error")
}
}
} catch {
Logger.mesh.error("Unable to save channel")
}
}
} label: {
Label(channel.mute ? "Show Alerts" : "Hide Alerts", systemImage: channel.mute ? "bell" : "bell.slash")
}
}
.confirmationDialog(
"This conversation will be deleted.",
isPresented: $isPresentingDeleteChannelMessagesConfirm,
titleVisibility: .visible
) {
Button(role: .destructive) {
Task {
await MeshPackets.shared.deleteChannelMessages(channel: channelToDeleteMessages!)
await MainActor.run {
channelToDeleteMessages = nil
}
}
} label: {
Text("Delete")
}
}
}
var body: some View {
VStack {
// Display Contacts for the rest of the non admin channels
if let node, let myInfo = node.myInfo {
List(selection: $channelSelection) {
ForEach(channels) { (channel: ChannelEntity) in
let hasMessages = channel.mostRecentPrivateMessage != nil
if !restrictedChannels.contains(channel.name?.lowercased() ?? "") {
makeChannelRow(myInfo: myInfo, channel: channel)
.alignmentGuide(.listRowSeparatorLeading) {
$0[.leading]
}
.frame(height: 62)
.contextMenu {
if hasMessages {
Button(role: .destructive) {
isPresentingDeleteChannelMessagesConfirm = true
channelToDeleteMessages = channel
} label: {
Label("Delete Messages", systemImage: "trash")
}
}
Button {
channel.mute.toggle()
do {
Task {
do {
_ = try await accessoryManager.saveChannel(channel: channel.protoBuf, fromUser: node.user!, toUser: node.user!)
Task { @MainActor in
do {
context.refresh(channel, mergeChanges: true)
try context.save()
} catch {
context.rollback()
Logger.data.error("💥 Save Channel Mute Error")
}
}
} catch {
Logger.mesh.error("Unable to save channel")
}
}
}
} label: {
Label(channel.mute ? "Show Alerts" : "Hide Alerts", systemImage: channel.mute ? "bell" : "bell.slash")
}
}
.confirmationDialog(
"This conversation will be deleted.",
isPresented: $isPresentingDeleteChannelMessagesConfirm,
titleVisibility: .visible
) {
Button(role: .destructive) {
Task {
await MeshPackets.shared.deleteChannelMessages(channel: channelToDeleteMessages!)
await MainActor.run {
context.refresh(channel, mergeChanges: true)
context.refresh(myInfo, mergeChanges: true)
// Reset state
channelToDeleteMessages = nil
}
}
} label: {
Text("Delete")
}
}
}
ForEach(visibleChannels) { (channel: ChannelEntity) in
makeChannelListItem(node: node, myInfo: myInfo, channel: channel)
}
}
.olderThanOS26 { $0.padding([.top, .bottom]) }

View file

@ -5,7 +5,7 @@
// Created by Garth Vander Houwen on 12/24/21.
//
import CoreData
import SwiftData
import MeshtasticProtobufs
import OSLog
import SwiftUI
@ -13,31 +13,28 @@ import SwiftUI
struct ChannelMessageList: View {
@EnvironmentObject var appState: AppState
@Environment(\.scenePhase) var scenePhase
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@FocusState var messageFieldFocused: Bool
@ObservedObject var myInfo: MyInfoEntity
@ObservedObject var channel: ChannelEntity
@Bindable var myInfo: MyInfoEntity
@Bindable var channel: ChannelEntity
@State private var replyMessageId: Int64 = 0
@State private var redrawTapbacksTrigger = UUID()
@AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1
@State private var messageToHighlight: Int64 = 0
@FetchRequest private var allPrivateMessages: FetchedResults<MessageEntity>
@Query private var allPrivateMessages: [MessageEntity]
init(myInfo: MyInfoEntity, channel: ChannelEntity) {
self.myInfo = myInfo
self.channel = channel
// Configure fetch request here
let request: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \MessageEntity.messageTimestamp, ascending: true)
]
request.predicate = NSPredicate(
format: "channel == %ld AND toUser == nil AND isEmoji == false",
channel.index
let channelIndex = channel.index
_allPrivateMessages = Query(
filter: #Predicate<MessageEntity> {
$0.channel == channelIndex && $0.toUser == nil && $0.isEmoji == false
},
sort: \MessageEntity.messageTimestamp
)
_allPrivateMessages = FetchRequest(fetchRequest: request)
}
func handleInteractionComplete() {
@ -53,7 +50,6 @@ struct ChannelMessageList: View {
try context.save()
Logger.data.info("📖 [App] All unread messages marked as read.")
appState.unreadChannelMessages = myInfo.unreadMessages
context.refresh(myInfo, mergeChanges: true)
} catch {
Logger.data.error("Failed to read messages: \(error.localizedDescription, privacy: .public)")
}

View file

@ -1,4 +1,4 @@
import CoreData
import SwiftData
import MeshtasticProtobufs
import SwiftUI
@ -6,9 +6,9 @@ struct ChannelMessageRow: View {
@EnvironmentObject var appState: AppState
// Core Data object observed for changes (like Tapbacks being received)
@ObservedObject var message: MessageEntity
@Bindable var message: MessageEntity
let allMessages: FetchedResults<MessageEntity> // The full list for reply lookup
let allMessages: [MessageEntity] // The full list for reply lookup
let previousMessage: MessageEntity?
let preferredPeripheralNum: Int
let channel: ChannelEntity
@ -24,7 +24,7 @@ struct ChannelMessageRow: View {
}
init(message: MessageEntity,
allMessages: FetchedResults<MessageEntity>,
allMessages: [MessageEntity],
previousMessage: MessageEntity?,
preferredPeripheralNum: Int,
channel: ChannelEntity,
@ -34,7 +34,7 @@ struct ChannelMessageRow: View {
scrollView: ScrollViewProxy,
onInteractionComplete: @escaping () -> Void) {
// Initialize ObservedObject with the concrete instance
self._message = ObservedObject(initialValue: message)
self.message = message
self.allMessages = allMessages
self.previousMessage = previousMessage
self.preferredPeripheralNum = preferredPeripheralNum

View file

@ -1,9 +1,9 @@
import SwiftUI
import CoreData
import SwiftData
import OSLog
struct MessageContextMenuItems: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
let message: MessageEntity
@ -148,7 +148,7 @@ struct MessageContextMenuItems: View {
}
private extension MessageDestination {
var managedObject: NSManagedObject {
var persistentModel: any PersistentModel {
switch self {
case let .user(user): return user
case let .channel(channel): return channel

View file

@ -20,7 +20,7 @@ struct MessageText: View {
)
static let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss:a")
static let timeFormatString = (localeTimeFormat ?? "j:mm:ss:a")
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
let message: MessageEntity
@ -284,10 +284,10 @@ struct MessageText: View {
)
await MainActor.run {
switch tapBackDestination {
case let .channel(channel):
context.refresh(channel, mergeChanges: true)
case let .user(user):
context.refresh(user, mergeChanges: true)
case .channel:
break
case .user:
break
}
}
} catch {

View file

@ -6,13 +6,13 @@
//
import SwiftUI
import CoreData
import SwiftData
import OSLog
import TipKit
struct Messages: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var router: Router
@Binding var unreadChannelMessages: Int
@ -116,10 +116,7 @@ struct Messages: View {
switch state {
case .channels(channelId: let channelId, messageId: _):
if let channelId {
channelSelection = node?.myInfo?.channels?.first(where: { channel in
guard let channel = channel as? ChannelEntity else { return false }
return channel.id == channelId
}) as? ChannelEntity
channelSelection = node?.myInfo?.channels.first { $0.id == channelId }
} else {
channelSelection = nil
userSelection = nil

View file

@ -2,7 +2,7 @@ import SwiftUI
import OSLog
struct RetryButton: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
let message: MessageEntity
@ -48,7 +48,6 @@ struct RetryButton: View {
// to messages is via a weak fetched property which is not updated by
// `bleManager.sendMessage` unlike the user entity.
Task { @MainActor in
context.refresh(channel, mergeChanges: true)
}
}
} catch {

View file

@ -2,7 +2,7 @@ import SwiftUI
import OSLog
struct TapbackResponses: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
let message: MessageEntity
let onRead: () -> Void

View file

@ -6,13 +6,13 @@
//
import SwiftUI
import CoreData
import SwiftData
import OSLog
import TipKit
struct UserList: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@State private var editingFilters = false
@State private var showingHelp = false
@ -69,30 +69,28 @@ struct UserList: View {
fileprivate struct FilteredUserList: View {
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@FetchRequest private var users: FetchedResults<UserEntity>
@Query(sort: [SortDescriptor(\UserEntity.lastMessage, order: .reverse),
SortDescriptor(\UserEntity.longName)])
private var allUsers: [UserEntity]
@Binding var userSelection: UserEntity?
@Binding var node: NodeInfoEntity?
@State private var isPresentingDeleteUserMessagesConfirm: Bool = false
@State private var userToDeleteMessages: UserEntity?
private var filters: NodeFilterParameters
init(withFilters: NodeFilterParameters, node: Binding<NodeInfoEntity?>, userSelection: Binding<UserEntity?>) {
let request: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(key: "lastMessage", ascending: false),
NSSortDescriptor(key: "userNode.favorite", ascending: false),
NSSortDescriptor(key: "pkiEncrypted", ascending: false),
NSSortDescriptor(key: "userNode.lastHeard", ascending: false),
NSSortDescriptor(key: "longName", ascending: true)
]
request.predicate = withFilters.buildPredicate()
self._users = FetchRequest(fetchRequest: request)
self.filters = withFilters
self._node = node
self._userSelection = userSelection
}
private var users: [UserEntity] {
allUsers.filter { filters.matches(user: $0) }
}
var body: some View {
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current)
let dateFormatString = (localeDateFormat ?? "MM/dd/YY")
@ -188,7 +186,6 @@ fileprivate struct FilteredUserList: View {
Logger.data.info("Unfavorited a node")
}
}
context.refresh(user, mergeChanges: true)
do {
try context.save()
} catch {
@ -226,7 +223,6 @@ fileprivate struct FilteredUserList: View {
Button(role: .destructive) {
Task {
await MeshPackets.shared.deleteUserMessages(user: userToDeleteMessages!)
context.refresh(node!.user!, mergeChanges: true)
}
} label: {
Text("Delete")
@ -239,6 +235,84 @@ fileprivate struct FilteredUserList: View {
}
}
fileprivate extension NodeFilterParameters {
func matches(user: UserEntity) -> Bool {
// Search text
if !searchText.isEmpty {
let text = searchText.lowercased()
let matchesSearch = [user.userId, user.numString, user.hwModel, user.hwDisplayName, user.longName, user.shortName]
.compactMap { $0?.lowercased() }
.contains { $0.contains(text) }
if !matchesSearch { return false }
}
// Mqtt and lora
if !(viaLora && viaMqtt) {
if viaLora {
if user.userNode?.viaMqtt == true { return false }
} else {
if user.userNode?.viaMqtt != true { return false }
}
}
// Roles
if roleFilter && !deviceRoles.isEmpty {
let userRole = Int(user.role)
if !deviceRoles.contains(userRole) { return false }
}
// Hops Away
if hopsAway == 0 {
if user.userNode?.hopsAway != 0 { return false }
} else if hopsAway > -1 {
let nodeHops = user.userNode?.hopsAway ?? 0
if nodeHops <= 0 || nodeHops > Int32(hopsAway) { return false }
}
// Online
if isOnline {
let twoHoursAgo = Calendar.current.date(byAdding: .minute, value: -120, to: Date()) ?? Date.distantPast
if let lastHeard = user.userNode?.lastHeard, lastHeard < twoHoursAgo { return false }
if user.userNode?.lastHeard == nil { return false }
}
// Encrypted
if isPkiEncrypted {
if !user.pkiEncrypted { return false }
}
// Favorites
if isFavorite {
if user.userNode?.favorite != true { return false }
}
// Distance
if distanceFilter {
if let poi = LocationsHandler.currentLocation,
poi.latitude != LocationsHandler.DefaultLocation.latitude,
poi.longitude != LocationsHandler.DefaultLocation.longitude {
let d = maxDistance * 1.1
let r: Double = 6371009
let meanLat = poi.latitude * .pi / 180
let deltaLat = d / r * 180 / .pi
let deltaLon = d / (r * cos(meanLat)) * 180 / .pi
let minLat = poi.latitude - deltaLat
let maxLat = poi.latitude + deltaLat
let minLon = poi.longitude - deltaLon
let maxLon = poi.longitude + deltaLon
let hasNearbyPosition = (user.userNode?.positions ?? []).contains { pos in
guard pos.latest else { return false }
let lon = Double(pos.longitudeI) / 1e7
let lat = Double(pos.latitudeI) / 1e7
return lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat
}
if !hasNearbyPosition { return false }
}
}
// Unmessagable filter
if user.unmessagable {
let hasMessages = !(user.receivedMessages ?? []).isEmpty || !(user.sentMessages ?? []).isEmpty
if !hasMessages { return false }
}
// Ignored
if user.userNode?.ignored == true { return false }
// Connected node
if user.numString == String(UserDefaults.preferredPeripheralNum) { return false }
return true
}
func buildPredicate() -> NSPredicate? {
var predicates: [NSPredicate] = []
// Search text predicates

View file

@ -6,7 +6,7 @@
//
import SwiftUI
import CoreData
import SwiftData
import OSLog
import MeshtasticProtobufs // Added to ensure RoutingError is accessible if needed
@ -14,21 +14,19 @@ struct UserMessageList: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.scenePhase) var scenePhase
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@FocusState var messageFieldFocused: Bool
@ObservedObject var user: UserEntity
@Bindable var user: UserEntity
@State private var replyMessageId: Int64 = 0
@State private var messageToHighlight: Int64 = 0
@State private var redrawTapbacksTrigger = UUID()
@AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1
@FetchRequest private var allPrivateMessages: FetchedResults<MessageEntity>
init(user: UserEntity) {
self.user = user
// Configure fetch request here
let request: NSFetchRequest<MessageEntity> = user.messageFetchRequest
_allPrivateMessages = FetchRequest(fetchRequest: request)
private var allPrivateMessages: [MessageEntity] {
let sent = user.sentMessages ?? []
let received = user.receivedMessages ?? []
return (sent + received)
.filter { !$0.isEmoji }
.sorted { $0.messageTimestamp < $1.messageTimestamp }
}
func handleInteractionComplete() {
@ -49,8 +47,6 @@ struct UserMessageList: View {
let connectedUser = connectedNode.user {
appState.unreadDirectMessages = connectedUser.unreadMessages(context: context, skipLastMessageCheck: true) // skipLastMessageCheck=true because we don't update lastMessage on our own connected node
}
context.refresh(user, mergeChanges: true)
} catch {
Logger.data.error("Failed to read direct messages: \(error.localizedDescription, privacy: .public)")
}

View file

@ -5,14 +5,14 @@
//  Copyright(c) Garth Vander Houwen 10/1/2025
//
import CoreData
import SwiftData
import MeshtasticProtobufs
import SwiftUI
struct UserMessageRow: View {
@EnvironmentObject var appState: AppState
@ObservedObject var message: MessageEntity
@Bindable var message: MessageEntity
let allMessages: [MessageEntity]
let previousMessage: MessageEntity?
let preferredPeripheralNum: Int
@ -38,7 +38,7 @@ struct UserMessageRow: View {
scrollView: ScrollViewProxy,
onInteractionComplete: @escaping () -> Void) {
// Initialize ObservedObject with the concrete instance
self._message = ObservedObject(initialValue: message)
self.message = message
self.allMessages = allMessages
self.previousMessage = previousMessage
self.preferredPeripheralNum = preferredPeripheralNum

View file

@ -6,20 +6,21 @@
//
import SwiftUI
import SwiftData
import Charts
import MeshtasticProtobufs
import OSLog
struct DetectionSensorLog: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@State private var isPresentingClearLogConfirm: Bool = false
@State var isExporting = false
@State var exportString = ""
@ObservedObject var node: NodeInfoEntity
@FetchRequest(sortDescriptors: [NSSortDescriptor(key: "messageTimestamp", ascending: false)],
predicate: NSPredicate(format: "portNum == %d", Int32(PortNum.detectionSensorApp.rawValue)), animation: .none)
private var detections: FetchedResults<MessageEntity>
@Bindable var node: NodeInfoEntity
@Query(filter: #Predicate<MessageEntity> { $0.portNum == 10 },
sort: \MessageEntity.messageTimestamp, order: .reverse)
private var detections: [MessageEntity]
var body: some View {
let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date())
@ -142,15 +143,17 @@ struct DetectionSensorLog: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
let user = UserEntity(context: context)
let user = UserEntity()
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return DetectionSensorLog(node: node)
DetectionSensorLog(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
.modelContainer(PersistenceController.preview.container)
}
*/

View file

@ -10,7 +10,7 @@ import OSLog
struct DeviceMetricsLog: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
@ -21,7 +21,7 @@ struct DeviceMetricsLog: View {
@State private var batteryChartColor: Color = .blue
@State private var airtimeChartColor: Color = .yellow
@State private var channelUtilizationChartColor: Color = .green
@ObservedObject var node: NodeInfoEntity
@Bindable var node: NodeInfoEntity
@State private var sortOrder = [KeyPathComparator(\TelemetryEntity.time, order: .reverse)]
@State private var selection: TelemetryEntity.ID?
@State private var chartSelection: Date?
@ -30,7 +30,7 @@ struct DeviceMetricsLog: View {
VStack {
if node.hasDeviceMetrics {
let oneWeekAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date())
let deviceMetrics = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).reversed() as? [TelemetryEntity] ?? []
let deviceMetrics = node.telemetries.filter { $0.metricsType == 0 }.reversed()
let chartData = deviceMetrics
.filter { $0.time != nil && $0.time! >= oneWeekAgo! }
.sorted { $0.time! < $1.time! }
@ -255,15 +255,17 @@ struct DeviceMetricsLog: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
let user = UserEntity(context: context)
let user = UserEntity()
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return DeviceMetricsLog(node: node)
DeviceMetricsLog(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
.modelContainer(PersistenceController.preview.container)
}
*/

View file

@ -7,33 +7,27 @@
import SwiftUI
import Charts
import OSLog
import CoreData
import SwiftData
struct EnvironmentMetricsLog: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@State private var isPresentingClearLogConfirm: Bool = false
@State var isExporting = false
@State var exportString = ""
@ObservedObject var node: NodeInfoEntity
@Bindable var node: NodeInfoEntity
@StateObject var columnList = MetricsColumnList.environmentDefaultColumns
@StateObject var seriesList = MetricsSeriesList.environmentDefaultChartSeries
@State var isEditingColumnConfiguration = false
@FetchRequest private var chartData: FetchedResults<TelemetryEntity>
init(node: NodeInfoEntity) {
self.node = node
// Build fetch request:
let request: NSFetchRequest<TelemetryEntity> = TelemetryEntity.fetchRequest()
private var chartData: [TelemetryEntity] {
let oneWeekAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date.distantPast
request.predicate = NSPredicate(format: "nodeTelemetry == %@ AND metricsType == 1 AND time >= %@", node, oneWeekAgo as NSDate)
request.sortDescriptors = [NSSortDescriptor(key: "time", ascending: false)]
_chartData = FetchRequest(fetchRequest: request)
return (node.telemetries ?? [])
.filter { $0.metricsType == 1 && ($0.time ?? Date.distantPast) >= oneWeekAgo }
.sorted { ($0.time ?? .distantPast) > ($1.time ?? .distantPast) }
}
var body: some View {
@ -187,15 +181,17 @@ struct EnvironmentMetricsLog: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
let user = UserEntity(context: context)
let user = UserEntity()
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return EnvironmentMetricsLog(node: node)
EnvironmentMetricsLog(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
.modelContainer(PersistenceController.preview.container)
}
*/

View file

@ -41,12 +41,14 @@ struct ClientHistoryButton: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
let connectedNode = NodeInfoEntity(context: context)
let connectedNode = NodeInfoEntity()
connectedNode.num = 987654321
return ClientHistoryButton(connectedNode: connectedNode, node: node)
ClientHistoryButton(connectedNode: connectedNode, node: node)
.environmentObject(AccessoryManager.shared)
}
*/

View file

@ -1,10 +1,10 @@
import CoreData
import SwiftData
import OSLog
import SwiftUI
struct DeleteNodeButton: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
var connectedNode: NodeInfoEntity
@ -65,13 +65,15 @@ struct DeleteNodeButton: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let connectedNode = NodeInfoEntity(context: context)
let connectedNode = NodeInfoEntity()
connectedNode.num = 987654321
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
return DeleteNodeButton(connectedNode: connectedNode, node: node)
DeleteNodeButton(connectedNode: connectedNode, node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
.modelContainer(PersistenceController.preview.container)
}
*/

View file

@ -1,4 +1,4 @@
import CoreData
import SwiftData
import SwiftUI
struct ExchangePositionsButton: View {
@ -62,12 +62,14 @@ struct ExchangePositionsButton: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
let connectedNode = NodeInfoEntity(context: context)
let connectedNode = NodeInfoEntity()
connectedNode.num = 987654321
return ExchangePositionsButton(node: node, connectedNode: connectedNode)
ExchangePositionsButton(node: node, connectedNode: connectedNode)
.environmentObject(AccessoryManager.shared)
}
*/

View file

@ -1,4 +1,4 @@
import CoreData
import SwiftData
import SwiftUI
import OSLog
@ -60,12 +60,14 @@ struct ExchangeUserInfoButton: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
let connectedNode = NodeInfoEntity(context: context)
let connectedNode = NodeInfoEntity()
connectedNode.num = 987654321
return ExchangeUserInfoButton(node: node, connectedNode: connectedNode)
ExchangeUserInfoButton(node: node, connectedNode: connectedNode)
.environmentObject(AccessoryManager.shared)
}
*/

View file

@ -1,13 +1,13 @@
import CoreData
import SwiftData
import OSLog
import SwiftUI
struct FavoriteNodeButton: View {
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@ObservedObject var node: NodeInfoEntity
@Bindable var node: NodeInfoEntity
@State var isShowingClientBaseConfirmation = false
var body: some View {
@ -80,15 +80,17 @@ struct FavoriteNodeButton: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
let user = UserEntity(context: context)
let user = UserEntity()
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return FavoriteNodeButton(node: node)
FavoriteNodeButton(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
.modelContainer(PersistenceController.preview.container)
}
*/

View file

@ -1,12 +1,12 @@
import CoreData
import SwiftData
import OSLog
import SwiftUI
struct IgnoreNodeButton: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@ObservedObject
@Bindable
var node: NodeInfoEntity
var body: some View {
@ -52,11 +52,13 @@ struct IgnoreNodeButton: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
return IgnoreNodeButton(node: node)
IgnoreNodeButton(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
.modelContainer(PersistenceController.preview.container)
}
*/

View file

@ -7,7 +7,7 @@
import SwiftUI
import CoreLocation
import CoreData
import SwiftData
import OSLog
struct NavigateToButton: View {
@ -21,11 +21,13 @@ struct NavigateToButton: View {
}
Logger.services.info("Fetching NodeInfoEntity for userNum: \(userNum, privacy: .public)")
let fetchRequest: NSFetchRequest<NodeInfoEntity> = NSFetchRequest(entityName: "NodeInfoEntity")
fetchRequest.predicate = NSPredicate(format: "num == %lld", Int64(userNum))
var descriptor = FetchDescriptor<NodeInfoEntity>(
predicate: #Predicate<NodeInfoEntity> { $0.num == userNum }
)
descriptor.fetchLimit = 1
do {
let fetchedNodes = try PersistenceController.shared.container.viewContext.fetch(fetchRequest)
let fetchedNodes = try PersistenceController.shared.context.fetch(descriptor)
guard let nodeInfo = fetchedNodes.first else {
Logger.services.error("NavigateToAction: Node with userNum \(userNum, privacy: .public) not found in Core Data")
return
@ -55,14 +57,16 @@ struct NavigateToButton: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
let user = UserEntity(context: context)
let user = UserEntity()
user.longName = "Test Node"
user.shortName = "TN"
user.num = 123456789
node.user = user
return NavigateToButton(node: node)
NavigateToButton(node: node)
}
*/

View file

@ -1,20 +1,19 @@
import CoreData
import SwiftData
import OSLog
import SwiftUI
struct NodeAlertsButton: View {
var context: NSManagedObjectContext
var context: ModelContext
@ObservedObject
@Bindable
var node: NodeInfoEntity
@ObservedObject
@Bindable
var user: UserEntity
var body: some View {
Button {
user.mute = !user.mute
context.refresh(node, mergeChanges: true)
do {
try context.save()
} catch {
@ -32,13 +31,15 @@ struct NodeAlertsButton: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
let user = UserEntity(context: context)
let user = UserEntity()
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return NodeAlertsButton(context: context, node: node, user: user)
NodeAlertsButton(context: context, node: node, user: user)
}
*/

View file

@ -44,14 +44,16 @@ struct TraceRouteButton: View {
}
}
// TODO: Fix preview for SwiftData
/*
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
let node = NodeInfoEntity()
node.num = 123456789
let user = UserEntity(context: context)
let user = UserEntity()
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return TraceRouteButton(node: node)
TraceRouteButton(node: node)
.environmentObject(AccessoryManager.shared)
}
*/

View file

@ -6,6 +6,7 @@
//
import SwiftUI
import SwiftData
import MapKit
import CoreLocation
import OSLog
@ -40,15 +41,16 @@ struct MeshMapContent: MapContent {
@AppStorage("mapOverlaysEnabled") private var showMapOverlays = false
@Binding var enabledOverlayConfigs: Set<UUID>
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .none)
var positions: FetchedResults<PositionEntity>
@FetchRequest(fetchRequest: WaypointEntity.allWaypointssFetchRequest(), animation: .none)
var waypoints: FetchedResults<WaypointEntity>
@FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)],
predicate: NSPredicate(format: "enabled == true", ""), animation: .none)
private var routes: FetchedResults<RouteEntity>
@Query(filter: #Predicate<PositionEntity> { $0.nodePosition != nil && $0.latest == true },
sort: \PositionEntity.time, order: .reverse)
var positions: [PositionEntity]
@Query(sort: \WaypointEntity.name, order: .reverse)
var waypoints: [WaypointEntity]
@Query(filter: #Predicate<RouteEntity> { $0.enabled == true },
sort: \RouteEntity.name)
private var routes: [RouteEntity]
@MapContentBuilder
var positionAnnotations: some MapContent {
@ -57,10 +59,10 @@ struct MeshMapContent: MapContent {
if (!showFavorites || (position.nodePosition?.favorite == true)) && !(position.nodePosition?.ignored == true) {
let coordinateForNodePin: CLLocationCoordinate2D = if position.isPreciseLocation {
// Precise location: place node pin at actual location.
position.coordinate
position.nodeCoordinate ?? LocationsHandler.DefaultLocation
} else {
// Imprecise location: fuzz slightly so overlapping nodes are visible and clickable at highest zoom levels.
position.fuzzedCoordinate
position.fuzzedNodeCoordinate ?? LocationsHandler.DefaultLocation
}
if 12...15 ~= position.precisionBits || position.precisionBits == 32 {
@ -131,7 +133,8 @@ struct MeshMapContent: MapContent {
@MapContentBuilder
var routeAnnotations: some MapContent {
ForEach(routes) { route in
if let routeLocations = route.locations, let locations = Array(routeLocations) as? [LocationEntity] {
if !route.locations.isEmpty {
let locations = route.locations
let routeCoords = locations.compactMap {(loc) -> CLLocationCoordinate2D in
return loc.locationCoordinate ?? LocationsHandler.DefaultLocation
}
@ -167,7 +170,7 @@ struct MeshMapContent: MapContent {
var waypointAnnotations: some MapContent {
if waypoints.count > 0, showWaypoints, let waypoints = Array(waypoints) as? [WaypointEntity] {
ForEach(waypoints, id: \.self) { waypoint in
Annotation(waypoint.name ?? "?", coordinate: waypoint.coordinate) {
Annotation(waypoint.name ?? "?", coordinate: waypoint.mapCoordinate) {
LazyVStack {
ZStack {
CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "📍"), color: Color.orange, circleSize: 40)

View file

@ -6,11 +6,11 @@
//
import SwiftUI
import MapKit
import CoreData
import SwiftData
struct NodeMapContent: MapContent {
@ObservedObject var node: NodeInfoEntity
@Bindable var node: NodeInfoEntity
/// Map State User Defaults
@AppStorage("meshMapShowNodeHistory") private var showNodeHistory = false
@AppStorage("meshMapShowRouteLines") private var showRouteLines = false
@ -28,7 +28,7 @@ struct NodeMapContent: MapContent {
@MapContentBuilder
var nodeMap: some MapContent {
let positionArray = node.positions?.array as? [PositionEntity] ?? []
let positionArray = node.positions
/// Node Color from node.num
let nodeColor = UIColor(hex: UInt32(node.num))
@ -49,7 +49,7 @@ struct NodeMapContent: MapContent {
let pp = PositionPrecision(rawValue: Int(position.precisionBits))
let radius: CLLocationDistance = pp?.precisionMeters ?? 0
if radius > 0.0 {
MapCircle(center: position.coordinate, radius: radius)
MapCircle(center: position.nodeCoordinate ?? LocationsHandler.DefaultLocation, radius: radius)
.foregroundStyle(Color(nodeColor).opacity(0.25))
.stroke(.white, lineWidth: 2)
}
@ -57,7 +57,7 @@ struct NodeMapContent: MapContent {
/// Lastest Position Pin
if position.latest {
/// Node Annotations
Annotation(position.latest ? node.user?.shortName ?? "?": "", coordinate: position.coordinate) {
Annotation(position.latest ? node.user?.shortName ?? "?": "", coordinate: position.nodeCoordinate ?? LocationsHandler.DefaultLocation) {
LazyVStack {
ZStack {
if pf.contains(.Heading) {
@ -106,7 +106,7 @@ struct NodeMapContent: MapContent {
// Having showNodeHistory enabled can be quite slow if there are thousands of history points.
if position.latest == false && node.favorite {
let headingDegrees = Angle.degrees(Double(position.heading))
Annotation("", coordinate: position.coordinate) {
Annotation("", coordinate: position.nodeCoordinate ?? LocationsHandler.DefaultLocation) {
if pf.contains(.Heading) {
Image(uiImage: prerenderedHistoryPointArrowImage)
.renderingMode(.original)
@ -160,7 +160,7 @@ struct NodeMapContent: MapContent {
@MapContentBuilder
var body: some MapContent {
if node.positions?.count ?? 0 > 0 {
if node.positions.count > 0 {
nodeMap
}
}

View file

@ -3,7 +3,7 @@ import UniformTypeIdentifiers
import OSLog
struct MapDataFiles: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@ObservedObject private var mapDataManager = MapDataManager.shared

View file

@ -6,6 +6,7 @@
//
import SwiftUI
import SwiftData
import CoreLocation
import MapKit
@ -30,10 +31,10 @@ private struct NodeMapContentEquatableWrapper<Content: View>: View, Equatable {
}
struct NodeMapSwiftUI: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
/// Parameters
@ObservedObject var node: NodeInfoEntity
@Bindable var node: NodeInfoEntity
@State var showUserLocation: Bool = false
@State var positions: [PositionEntity] = []
/// Map State User Defaults
@ -58,11 +59,8 @@ struct NodeMapSwiftUI: View {
@State private var mapRegion = MKCoordinateRegion.init()
@FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: false)],
predicate: NSPredicate(
format: "expire == nil || expire >= %@", Date() as NSDate
), animation: .none)
private var waypoints: FetchedResults<WaypointEntity>
@Query(sort: \WaypointEntity.name, order: .reverse)
private var waypoints: [WaypointEntity]
var body: some View {
if node.hasPositions {
@ -78,7 +76,7 @@ struct NodeMapSwiftUI: View {
configuredMap
}
}
.navigationBarTitle(String((node.user?.shortName ?? "Unknown".localized) + (" \(node.positions?.count ?? 0) points")), displayMode: .inline)
.navigationBarTitle(String((node.user?.shortName ?? "Unknown".localized) + (" \(node.positions.count) points")), displayMode: .inline)
.navigationBarItems(trailing:
ZStack {
ConnectedDevice(
@ -123,8 +121,8 @@ struct NodeMapSwiftUI: View {
}
private var mapContentSignature: NodeMapContentSignature {
let positionCount = node.positions?.count ?? 0
let lastPositionTime = (node.positions?.lastObject as? PositionEntity)?.time
let positionCount = node.positions.count
let lastPositionTime = node.positions.last?.time
return NodeMapContentSignature(nodeNum: node.num, positionCount: positionCount, lastPositionTime: lastPositionTime, showNodeHistory: showNodeHistory, showRouteLines: showRouteLines, showConvexHull: showConvexHull, favorite: node.favorite)
}
@ -208,7 +206,7 @@ struct NodeMapSwiftUI: View {
.glassButtonStyle()
}
if node.positions?.count ?? 0 > 1 {
if node.positions.count > 1 {
Button(action: {
if isLookingAround {
isLookingAround = false
@ -241,15 +239,15 @@ struct NodeMapSwiftUI: View {
private func handleNodeChange() {
isLookingAround = false
isShowingAltitude = false
let newMostRecent = node.positions?.lastObject as? PositionEntity
if node.positions?.count ?? 0 > 1 {
let newMostRecent = node.positions.last
if node.positions.count > 1 {
position = .automatic
} else if let mrCoord = newMostRecent?.coordinate {
} else if let mrCoord = newMostRecent?.nodeCoordinate {
position = .camera(MapCamera(centerCoordinate: mrCoord, distance: distance, heading: 0, pitch: 0))
}
if let newMostRecent {
if let newMostRecent, let coord = newMostRecent.nodeCoordinate {
Task {
scene = try? await fetchScene(for: newMostRecent.coordinate)
scene = try? await fetchScene(for: coord)
}
}
}
@ -257,13 +255,13 @@ struct NodeMapSwiftUI: View {
private func handleAppear() {
UIApplication.shared.isIdleTimerDisabled = true
updateMapStyle(for: selectedMapLayer)
let mostRecent = node.positions?.lastObject as? PositionEntity
if node.positions?.count ?? 0 > 1 {
let mostRecent = node.positions.last
if node.positions.count > 1 {
position = .automatic
} else if let mrCoord = mostRecent?.coordinate {
} else if let mrCoord = mostRecent?.nodeCoordinate {
position = .camera(MapCamera(centerCoordinate: mrCoord, distance: distance, heading: 0, pitch: 0))
}
if scene == nil, let mrCoord = mostRecent?.coordinate {
if scene == nil, let mrCoord = mostRecent?.nodeCoordinate {
Task {
scene = try? await fetchScene(for: mrCoord)
}

View file

@ -16,16 +16,12 @@ struct PositionAltitude {
struct PositionAltitudeChart: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var node: NodeInfoEntity
@Bindable var node: NodeInfoEntity
@State private var lineWidth = 2.0
var data: [PositionAltitude] {
let fiveYearsAgo = Calendar.current.date(byAdding: .year, value: -5, to: Date())
guard let nodePositions = node.positions,
let positions = Array(nodePositions) as? [PositionEntity]
else {
return []
}
let positions = node.positions
let filteredPositions = positions.filter({$0.time != nil && ($0.time ?? fiveYearsAgo!) > fiveYearsAgo!})
return filteredPositions.map {

View file

@ -11,7 +11,7 @@ import MapKit
struct PositionPopover: View {
@ObservedObject var locationsHandler = LocationsHandler.shared
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var appState: AppState
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
@Environment(\.dismiss) private var dismiss
@ -81,7 +81,7 @@ struct PositionPopover: View {
.padding(.bottom, 5)
/// Coordinate
Label {
Text("\(String(format: "%.6f", position.coordinate.latitude)), \(String(format: "%.6f", position.coordinate.longitude))")
Text("\(String(format: "%.6f", position.nodeCoordinate?.latitude ?? 0)), \(String(format: "%.6f", position.nodeCoordinate?.longitude ?? 0))")
.textSelection(.enabled)
.foregroundColor(.primary)
.font(idiom == .phone ? .callout : .body)
@ -169,7 +169,8 @@ struct PositionPopover: View {
if let lastLocation = locationsHandler.locationsArray.last {
/// Distance
if lastLocation.distance(from: CLLocation(latitude: LocationsHandler.DefaultLocation.latitude, longitude: LocationsHandler.DefaultLocation.longitude)) > 0.0 {
let metersAway = position.coordinate.distance(from: CLLocationCoordinate2D(latitude: lastLocation.coordinate.latitude, longitude: lastLocation.coordinate.longitude))
let posCoord = position.nodeCoordinate ?? LocationsHandler.DefaultLocation
let metersAway = posCoord.distance(from: CLLocationCoordinate2D(latitude: lastLocation.coordinate.latitude, longitude: lastLocation.coordinate.longitude))
Label {
Text("Distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway)))")
.foregroundColor(.primary)
@ -263,7 +264,7 @@ struct PositionPopover: View {
.presentationBackgroundInteraction(.enabled(upThrough: .large))
.navigationDestination(isPresented: $navigateToCompass) {
CompassView(
waypointLocation: position.coordinate,
waypointLocation: position.nodeCoordinate ?? LocationsHandler.DefaultLocation,
waypointLongName: position.nodePosition?.user?.longName ?? "Unknown node",
waypointShortName: position.nodePosition?.user?.shortName ?? "???",
color: (position.nodePosition?.user?.num != nil && position.nodePosition?.user?.num != 0) ? Color(UIColor(hex: UInt32(position.nodePosition!.user!.num))) : .orange

View file

@ -10,12 +10,12 @@ import MapKit
import MeshtasticProtobufs
import OSLog
import SwiftUI
import CoreData
@preconcurrency import SwiftData
struct WaypointForm: View {
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State var waypoint: WaypointEntity
let distanceFormatter = MKDistanceFormatter()
@ -44,20 +44,20 @@ struct WaypointForm: View {
Divider()
Form {
if let cl = LocationsHandler.currentLocation {
let distance = CLLocation(latitude: cl.latitude, longitude: cl.longitude).distance(from: CLLocation(latitude: waypoint.coordinate.latitude, longitude: waypoint.coordinate.longitude ))
let distance = CLLocation(latitude: cl.latitude, longitude: cl.longitude).distance(from: CLLocation(latitude: waypoint.mapCoordinate.latitude, longitude: waypoint.mapCoordinate.longitude ))
Section(header: Text("Coordinate") ) {
HStack {
Text("Location:")
.foregroundColor(.secondary)
Text("\(String(format: "%.5f", waypoint.coordinate.latitude) + "," + String(format: "%.5f", waypoint.coordinate.longitude))")
Text("\(String(format: "%.5f", waypoint.mapCoordinate.latitude) + "," + String(format: "%.5f", waypoint.mapCoordinate.longitude))")
.textSelection(.enabled)
.foregroundColor(.secondary)
.font(.caption)
}
Button {
waypoint.coordinate.longitude = cl.longitude
waypoint.coordinate.latitude = cl.latitude
waypoint.longitudeI = Int32(cl.longitude * 1e7)
waypoint.latitudeI = Int32(cl.latitude * 1e7)
} label: {
HStack {
Text("Use my Location")
@ -66,7 +66,7 @@ struct WaypointForm: View {
}
.accessibilityLabel("Set to current location")
HStack {
if waypoint.coordinate.latitude != 0 && waypoint.coordinate.longitude != 0 {
if waypoint.mapCoordinate.latitude != 0 && waypoint.mapCoordinate.longitude != 0 {
DistanceText(meters: distance)
.foregroundColor(Color.gray)
}
@ -290,7 +290,7 @@ struct WaypointForm: View {
Text(waypoint.name ?? "?")
.font(.largeTitle)
Spacer()
if waypoint.locked > 0 && waypoint.locked != UInt32(accessoryManager.activeDeviceNum ?? 0) {
if waypoint.locked {
Image(systemName: "lock.fill")
.font(.largeTitle)
} else {
@ -361,7 +361,7 @@ struct WaypointForm: View {
Label {
Text("Coordinates:")
.foregroundColor(.primary)
Text("\(String(format: "%.6f", waypoint.coordinate.latitude)), \(String(format: "%.6f", waypoint.coordinate.longitude))")
Text("\(String(format: "%.6f", waypoint.mapCoordinate.latitude)), \(String(format: "%.6f", waypoint.mapCoordinate.longitude))")
.textSelection(.enabled)
.foregroundColor(.secondary)
.font(.caption2)
@ -371,7 +371,7 @@ struct WaypointForm: View {
.padding(.bottom)
// Drop Maps Pin
Button(action: {
if let url = URL(string: "http://maps.apple.com/?ll=\(waypoint.coordinate.latitude),\(waypoint.coordinate.longitude)&q=\(waypoint.name ?? "Dropped Pin")") {
if let url = URL(string: "http://maps.apple.com/?ll=\(waypoint.mapCoordinate.latitude),\(waypoint.mapCoordinate.longitude)&q=\(waypoint.name ?? "Dropped Pin")") {
UIApplication.shared.open(url)
}
}) {
@ -411,7 +411,7 @@ struct WaypointForm: View {
}
/// Distance
if let cl = LocationsHandler.currentLocation {
let metersAway = waypoint.coordinate.distance(from: cl)
let metersAway = waypoint.mapCoordinate.distance(from: cl)
if metersAway > 0.0 {
Label {
Text("Distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway)))")
@ -481,9 +481,8 @@ struct WaypointForm: View {
} else {
expires = false
}
if waypoint.locked > 0 {
if waypoint.locked {
locked = true
lockedTo = waypoint.locked
}
} else {
name = ""
@ -492,8 +491,8 @@ struct WaypointForm: View {
expires = false
expire = Date.now.addingTimeInterval(60 * 480)
icon = "📍"
latitude = waypoint.coordinate.latitude
longitude = waypoint.coordinate.longitude
latitude = waypoint.mapCoordinate.latitude
longitude = waypoint.mapCoordinate.longitude
}
}
.presentationBackgroundInteraction(.enabled(upThrough: .fraction(0.85)))
@ -504,11 +503,14 @@ struct WaypointForm: View {
private func fetchNodeInfo() async {
// --- Fetch createdBy node ---
if waypoint.createdBy != 0 {
let createdByFetch: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
createdByFetch.predicate = NSPredicate(format: "num == %lld", Int64(waypoint.createdBy))
createdByFetch.fetchLimit = 1
let createdByNum = Int64(waypoint.createdBy)
var createdByDescriptor = FetchDescriptor<NodeInfoEntity>(
predicate: #Predicate<NodeInfoEntity> { $0.num == createdByNum }
)
createdByDescriptor.fetchLimit = 1
do {
let nodes = try context.fetch(createdByFetch)
let nodes = try context.fetch(createdByDescriptor)
createdByNode = nodes.first
} catch {
Logger.services.warning("Error fetching createdBy node: \(error.localizedDescription)")
@ -518,11 +520,14 @@ struct WaypointForm: View {
// --- Fetch lastUpdatedBy node (only if different from createdBy) ---
if waypoint.lastUpdatedBy != 0,
waypoint.lastUpdatedBy != waypoint.createdBy {
let updatedByFetch: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
updatedByFetch.predicate = NSPredicate(format: "num == %lld", Int64(waypoint.lastUpdatedBy))
updatedByFetch.fetchLimit = 1
let updatedByNum = Int64(waypoint.lastUpdatedBy)
var updatedByDescriptor = FetchDescriptor<NodeInfoEntity>(
predicate: #Predicate<NodeInfoEntity> { $0.num == updatedByNum }
)
updatedByDescriptor.fetchLimit = 1
do {
let nodes = try context.fetch(updatedByFetch)
let nodes = try context.fetch(updatedByDescriptor)
lastUpdatedByNode = nodes.first
} catch {
Logger.services.warning("Error fetching lastUpdatedBy node: \(error.localizedDescription)")

View file

@ -19,13 +19,13 @@ struct NodeDetail: View {
var modemPreset: ModemPresets = ModemPresets(
rawValue: UserDefaults.modemPreset
) ?? ModemPresets.longFast
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@State private var showingShutdownConfirm: Bool = false
@State private var showingRebootConfirm: Bool = false
@State private var dateFormatRelative: Bool = true
var connectedNode: NodeInfoEntity?
@ObservedObject var node: NodeInfoEntity
@Bindable var node: NodeInfoEntity
@State private var environmentSectionHeight: CGFloat = 0
@State var showingCompassSheet = false
@ -69,7 +69,7 @@ struct NodeDetail: View {
}
.accessibilityElement(children: .combine)
}
if node.telemetries?.count ?? 0 > 0 {
if node.telemetries.count > 0 {
Spacer()
BatteryGauge(node: node)
}
@ -134,9 +134,7 @@ struct NodeDetail: View {
}
Spacer()
Button(action: {
context.perform {
UIPasteboard.general.string = publicKey
}
UIPasteboard.general.string = publicKey
}) {
HStack {
Image(systemName: "key.horizontal.fill")
@ -185,7 +183,7 @@ struct NodeDetail: View {
}
.accessibilityElement(children: .combine)
}
if let dm = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).lastObject as? TelemetryEntity, let uptimeSeconds = dm.uptimeSeconds {
if let dm = node.telemetries.filter({ $0.metricsType == 0 }).last, let uptimeSeconds = dm.uptimeSeconds {
HStack {
Label {
Text("\("Uptime".localized)")
@ -401,7 +399,7 @@ struct NodeDetail: View {
.symbolRenderingMode(.multicolor)
}
}
.disabled(node.traceRoutes?.count ?? 0 == 0)
.disabled(node.traceRoutes.count == 0)
NavigationLink {
PowerMetricsLog(node: node)
} label: {

View file

@ -11,7 +11,7 @@ import MapKit
struct NodeInfoItem: View {
@ObservedObject var node: NodeInfoEntity
@Bindable var node: NodeInfoEntity
var body: some View {
@ -48,10 +48,10 @@ struct NodeInfoItem: View {
.font(.caption2)
}
}
let deviceMetrics = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0"))
if deviceMetrics?.count ?? 0 >= 1 {
let deviceMetrics = node.telemetries.filter { $0.metricsType == 0 }
if deviceMetrics.count >= 1 {
Divider()
let mostRecent = deviceMetrics?.lastObject as? TelemetryEntity
let mostRecent = deviceMetrics.last
VStack(alignment: .center) {
BatteryGauge(batteryLevel: Double(mostRecent?.batteryLevel ?? 0))
if mostRecent?.voltage ?? 0 > 0 {

View file

@ -11,7 +11,7 @@ import MapKit
struct NodeInfoItem: View {
@ObservedObject var node: NodeInfoEntity
@Bindable var node: NodeInfoEntity
@State private var currentDevice: DeviceHardware?
var body: some View {

View file

@ -88,7 +88,7 @@ struct NodeListItem: View {
return desc
}
@ObservedObject var node: NodeInfoEntity
@Bindable var node: NodeInfoEntity
var isDirectlyConnected: Bool
var connectedNode: Int64
var modemPreset: ModemPresets = ModemPresets(rawValue: UserDefaults.modemPreset) ?? ModemPresets.longFast
@ -109,7 +109,7 @@ struct NodeListItem: View {
}
var locationData: (PositionEntity, CLLocation)? {
guard let lastPostion = node.positions?.lastObject as? PositionEntity else {
guard let lastPostion = node.positions.last else {
return nil
}
guard let currentLocation = LocationsHandler.shared.locationsArray.last else {
@ -172,7 +172,7 @@ struct NodeListItem: View {
text: "Store & Forward".localized)
}
if node.positions?.count ?? 0 > 0 && connectedNode != node.num {
if node.positions.count > 0 && connectedNode != node.num {
HStack {
if let (lastPostion, myCoord) = locationData {
let nodeCoord = CLLocation(latitude: lastPostion.nodeCoordinate!.latitude, longitude: lastPostion.nodeCoordinate!.longitude)
@ -298,9 +298,8 @@ struct IconAndText: View {
IconAndText(systemName: "antenna.radiowaves.left.and.right.circle.fill", text: "foo")
IconAndText(systemName: "antenna.radiowaves.left.and.right.circle", text: "bar")
NodeListItem(node: {
let context = PersistenceController.preview.container.viewContext
let nodeInfo = NodeInfoEntity(context: context)
let user = UserEntity(context: context)
let nodeInfo = NodeInfoEntity()
let user = UserEntity()
user.longName = "Test User"
user.shortName = "TU"
nodeInfo.user = user

View file

@ -8,7 +8,7 @@ import CoreImage.CIFilterBuiltins
#if canImport(UIKit)
import UIKit
#endif
import CoreData
import SwiftData
import MeshtasticProtobufs
import OSLog

View file

@ -6,7 +6,7 @@
//
import SwiftUI
import CoreData
import SwiftData
import CoreLocation
import Foundation
import OSLog
@ -14,7 +14,7 @@ import MapKit
struct MeshMap: View {
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@ObservedObject
@ -101,7 +101,7 @@ struct MeshMap: View {
centerMapAt(coordinate: coordinate)
newWaypointCoord = coordinate
editingWaypoint = WaypointEntity(context: context)
editingWaypoint = WaypointEntity()
editingWaypoint!.name = "Waypoint Pin"
editingWaypoint!.expire = Date.now.addingTimeInterval(60 * 480)
editingWaypoint!.latitudeI = Int32((newWaypointCoord?.latitude ?? 0) * 1e7)

View file

@ -7,14 +7,14 @@
import SwiftUI
import CoreLocation
import OSLog
import CoreData
import SwiftData
import Foundation
struct NodeList: View {
/// Debounce delay for node selection changes (100ms)
private static let nodeSelectionDebounceNs: UInt64 = 100_000_000
@Environment(\.managedObjectContext) var context
@Environment(\.modelContext) private var context
@EnvironmentObject var accessoryManager: AccessoryManager
@StateObject var router: Router
@State private var selectedNode: NodeInfoEntity?
@ -27,6 +27,7 @@ struct NodeList: View {
@State private var shareContactNode: NodeInfoEntity?
@StateObject var filters = NodeFilterParameters()
@State var isEditingFilters = false
@State private var filteredNodeCount: Int = 0
@SceneStorage("selectedDetailView") var selectedDetailView: String?
var connectedNode: NodeInfoEntity? {
@ -45,7 +46,8 @@ struct NodeList: View {
connectedNode: connectedNode,
isPresentingDeleteNodeAlert: $isPresentingDeleteNodeAlert,
deleteNodeId: $deleteNodeId,
shareContactNode: $shareContactNode
shareContactNode: $shareContactNode,
filteredNodeCount: $filteredNodeCount
)
.sheet(isPresented: $isEditingFilters) {
NodeListFilter(
@ -72,7 +74,7 @@ struct NodeList: View {
.searchable(text: $filters.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Find a node")
.autocorrectionDisabled(true)
.scrollDismissesKeyboard(.immediately)
.navigationTitle(String.localizedStringWithFormat("Nodes (%@)".localized, String(getNodeCount())))
.navigationTitle(String.localizedStringWithFormat("Nodes (%@)".localized, String(filteredNodeCount)))
.listStyle(.plain)
.alert("Position Exchange Requested", isPresented: $isPresentingPositionSentAlert) {
Button("OK") { }.keyboardShortcut(.defaultAction)
@ -152,12 +154,6 @@ struct NodeList: View {
}
}
// Helper to get the count of nodes for the navigation title
private func getNodeCount() -> Int {
let request: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
request.predicate = filters.buildPredicate()
return (try? context.count(for: request)) ?? 0
}
}
//
@ -166,8 +162,9 @@ struct NodeList: View {
//
fileprivate struct FilteredNodeList: View {
@EnvironmentObject var accessoryManager: AccessoryManager
@FetchRequest private var nodes: FetchedResults<NodeInfoEntity>
@Environment(\.managedObjectContext) var context
@Query(sort: \NodeInfoEntity.lastHeard, order: .reverse)
private var allNodes: [NodeInfoEntity]
@Environment(\.modelContext) private var context
var router: Router
@Binding var selectedNode: NodeInfoEntity?
@ -175,6 +172,8 @@ fileprivate struct FilteredNodeList: View {
@Binding var isPresentingDeleteNodeAlert: Bool
@Binding var deleteNodeId: Int64
@Binding var shareContactNode: NodeInfoEntity?
@Binding var filteredNodeCount: Int
private var filters: NodeFilterParameters
// The initializer for the FetchRequest
init(
@ -184,26 +183,21 @@ fileprivate struct FilteredNodeList: View {
connectedNode: NodeInfoEntity?,
isPresentingDeleteNodeAlert: Binding<Bool>,
deleteNodeId: Binding<Int64>,
shareContactNode: Binding<NodeInfoEntity?>
shareContactNode: Binding<NodeInfoEntity?>,
filteredNodeCount: Binding<Int>
) {
self.router = router
let request: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(key: "ignored", ascending: true),
NSSortDescriptor(key: "favorite", ascending: false),
NSSortDescriptor(key: "lastHeard", ascending: false),
NSSortDescriptor(key: "user.longName", ascending: true)
]
request.predicate = withFilters.buildPredicate()
request.fetchBatchSize = 50
request.relationshipKeyPathsForPrefetching = ["user"]
self._nodes = FetchRequest(fetchRequest: request)
self.filters = withFilters
self._selectedNode = selectedNode
self.connectedNode = connectedNode
self._isPresentingDeleteNodeAlert = isPresentingDeleteNodeAlert
self._deleteNodeId = deleteNodeId
self._shareContactNode = shareContactNode
self._filteredNodeCount = filteredNodeCount
}
private var nodes: [NodeInfoEntity] {
allNodes.filter { filters.matches(node: $0) }
}
// The body of the view
@ -243,9 +237,11 @@ fileprivate struct FilteredNodeList: View {
}
.onAppear {
router.updateNodeIndex(from: nodes)
filteredNodeCount = nodes.count
}
.onChange(of: nodes.map(\.objectID)) { _, _ in
.onChange(of: nodes.count) { _, newCount in
router.updateNodeIndex(from: nodes)
filteredNodeCount = newCount
}
}
@ -330,6 +326,73 @@ fileprivate struct FilteredNodeList: View {
//
fileprivate extension NodeFilterParameters {
func matches(node: NodeInfoEntity) -> Bool {
// Search text
if !searchText.isEmpty {
let text = searchText.lowercased()
let fields = [node.user?.userId, node.user?.numString, node.user?.hwModel,
node.user?.hwDisplayName, node.user?.longName, node.user?.shortName]
let matchesSearch = fields.compactMap { $0?.lowercased() }.contains { $0.contains(text) }
if !matchesSearch { return false }
}
// Favorite
if isFavorite && !node.favorite { return false }
// Via Lora/MQTT
if viaLora && !viaMqtt && node.viaMqtt { return false }
if !viaLora && viaMqtt && !node.viaMqtt { return false }
// Roles
if roleFilter && !deviceRoles.isEmpty {
let userRole = Int(node.user?.role ?? 0)
if !deviceRoles.contains(userRole) { return false }
}
// Hops Away
if hopsAway == 0 && node.hopsAway != 0 { return false }
if hopsAway > 0 && (node.hopsAway <= 0 || node.hopsAway > Int32(hopsAway)) { return false }
// Online
if isOnline {
let twoHoursAgo = Calendar.current.date(byAdding: .minute, value: -120, to: Date()) ?? Date.distantPast
if let lastHeard = node.lastHeard, lastHeard < twoHoursAgo { return false }
if node.lastHeard == nil { return false }
}
// Encrypted
if isPkiEncrypted && node.user?.pkiEncrypted != true { return false }
// Ignored
if isIgnored {
if !node.ignored { return false }
} else {
if node.ignored { return false }
}
// Environment
if isEnvironment {
let hasEnvTelemetry = (node.telemetries ?? []).contains { $0.metricsType == 1 }
if !hasEnvTelemetry { return false }
}
// Distance
if distanceFilter {
if let poi = LocationsHandler.currentLocation,
poi.latitude != LocationsHandler.DefaultLocation.latitude,
poi.longitude != LocationsHandler.DefaultLocation.longitude {
let d = maxDistance * 1.1
let r: Double = 6371009
let meanLat = poi.latitude * .pi / 180
let deltaLat = d / r * 180 / .pi
let deltaLon = d / (r * cos(meanLat)) * 180 / .pi
let minLat = poi.latitude - deltaLat
let maxLat = poi.latitude + deltaLat
let minLon = poi.longitude - deltaLon
let maxLon = poi.longitude + deltaLon
let hasNearbyPosition = (node.positions ?? []).contains { pos in
guard pos.latest else { return false }
let lon = Double(pos.longitudeI) / 1e7
let lat = Double(pos.latitudeI) / 1e7
return lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat
}
if !hasNearbyPosition { return false }
}
}
return true
}
func buildPredicate() -> NSPredicate? {
var predicates: [NSPredicate] = []

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