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

This commit is contained in:
Garth Vander Houwen 2025-10-28 07:03:12 -07:00
commit ebc84d3efd
24 changed files with 481 additions and 134 deletions

View file

@ -18921,6 +18921,13 @@
}
}
}
},
"Last seen device:" : {
"comment" : "A label displayed next to the last seen device text in the `DeviceConnectRow`.",
"isCommentAutoGenerated" : true
},
"Last seen device: %@" : {
},
"Latitude" : {
"localizations" : {
@ -20441,6 +20448,9 @@
}
}
}
},
"Manual Connections" : {
},
"Map Data" : {
"localizations" : {
@ -20926,8 +20936,8 @@
}
}
},
"Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings." : {
"comment" : "A description of Meshtastic's data collection practices.",
"Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings." : {
"comment" : "Privacy policy text for Meshtastic.",
"isCommentAutoGenerated" : true
},
"Meshtastic Node %@ has shared channels with you" : {
@ -28470,6 +28480,7 @@
}
},
"Received Ack" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@ -28532,8 +28543,12 @@
}
}
}
},
"Received Ack: %@" : {
},
"Recipient Ack" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@ -28596,6 +28611,9 @@
}
}
}
},
"Recipient Ack: %@" : {
},
"Recording route" : {
"localizations" : {
@ -28779,6 +28797,16 @@
}
}
},
"Relayed by %d %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Relayed by %1$d %2$@"
}
}
}
},
"Release Notes" : {
"localizations" : {
"it" : {

View file

@ -42,6 +42,7 @@
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 */; };
2349A04A2EAE4DA30060A581 /* ManualConnectionList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2349A0492EAE4DA30060A581 /* ManualConnectionList.swift */; };
2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; };
2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; };
2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */; };
@ -357,6 +358,7 @@
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>"; };
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = "<group>"; };
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = "<group>"; };
2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultSeries.swift; sourceTree = "<group>"; };
@ -832,6 +834,7 @@
23D316922E5618D2002FA4FB /* AsyncGate.swift */,
23E23F912E392C2B00919073 /* LogRecord+StringRepresentation.swift */,
23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */,
2349A0492EAE4DA30060A581 /* ManualConnectionList.swift */,
);
path = Helpers;
sourceTree = "<group>";
@ -1841,6 +1844,7 @@
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 */,

View file

@ -67,7 +67,12 @@ extension AccessoryManager {
}
self.activeConnection = (device: device, connection: connection)
if UserDefaults.preferredPeripheralId.count < 1 {
// if we don't have a peripheralId, set it now at the beginning of the
// connect process (because I think it is used in other parts of the app
// during the connect process?
// Otherwise, UserDefault.preferredPeripheralId is set in the Connect
// view, as part of the "Connect to new radio?" confirmation dialog logic.
if UserDefaults.preferredPeripheralId.isEmpty {
UserDefaults.preferredPeripheralId = device.id.uuidString
}
} catch let error as CBError where error.code == .peerRemovedPairingInformation {
@ -168,6 +173,15 @@ extension AccessoryManager {
// We have an active connection
self.updateDevice(deviceId: device.id, key: \.connectionState, value: .connected)
self.updateState(.subscribed)
// If we successfully connected to a manual connection, then save it to the list
// Remember, Device is a value type (struct) so don't use use `device` here, thats
// The value at the instantiation of the connect process. We want the currently
// updated device object in `activeConnection` with its additonal metadata from
// NodeInfo packets.
if let activeDevice = self.activeConnection?.device, activeDevice.isManualConnection {
ManualConnectionList.shared.insert(device: activeDevice)
}
}
// Step 8: Update UI and status to connected

View file

@ -116,6 +116,15 @@ extension AccessoryManager {
updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName ?? "?")
updateDevice(deviceId: activeDevice.id, key: \.longName, value: user.longName ?? "Unknown".localized)
updateDevice(deviceId: activeDevice.id, key: \.hardwareModel, value: user.hwModel)
if activeDevice.isManualConnection {
// We just received a NodeInfo for the currently connected node and this is a
// manual connection. Update the metadata for the device entry in UserDefaults
// with this information for better display later
ManualConnectionList.shared.updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName)
ManualConnectionList.shared.updateDevice(deviceId: activeDevice.id, key: \.longName, value: user.longName)
ManualConnectionList.shared.updateDevice(deviceId: activeDevice.id, key: \.hardwareModel, value: user.hwModel)
}
}
}
}

View file

@ -302,9 +302,9 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
Logger.transport.error("updateDevice<T> with nil deviceId")
return
}
// Update the active device
if let activeConnection {
// Update the active device if the UUID's match
if let activeConnection, activeConnection.device.id == deviceId {
var device = activeConnection.device
if device[keyPath: key] != value {
// Update the @Published stuff for the UI
@ -714,7 +714,8 @@ extension AccessoryManager {
await self.heartbeatTimer?.cancel(withReason: "Duplicate setup, cancelling previous timer")
self.heartbeatTimer = nil
}
self.heartbeatTimer = ResettableTimer(isRepeating: true, debugName: "Send Heartbeat") {
self.heartbeatTimer = ResettableTimer(isRepeating: true, debugName: Bundle.main.isDebug ? "Send Heartbeat" : nil) {
Logger.transport.debug("💓 [Heartbeat] Sending periodic heartbeat")
try? await self.sendHeartbeat()
}
@ -722,7 +723,7 @@ extension AccessoryManager {
// We can send heartbeats for older versions just fine, but only 2.7.4 and up will respond with
// a definite queueStatus packet.
if self.checkIsVersionSupported(forVersion: "2.7.4") {
self.heartbeatResponseTimer = ResettableTimer(isRepeating: false, debugName: "Heartbeat Timeout") { @MainActor in
self.heartbeatResponseTimer = ResettableTimer(isRepeating: false, debugName: Bundle.main.isDebug ? "Heartbeat Timeout" : nil) { @MainActor in
Logger.transport.error("💓 [Heartbeat] Connection Timeout: Did not receive a packet after heartbeat.")
// If we're in the middle of a connection cancel it.
await self.connectionStepper?.cancel()

View file

@ -0,0 +1,61 @@
//
// ManualConnectionList.swift
// Meshtastic
//
// Created by jake on 10/26/25.
//
import Foundation
// Maintains an observable list of devices that's backed by UserDefaults
public class ManualConnectionList: ObservableObject {
static let shared = ManualConnectionList()
@Published private var _list: [Device]
private init() {
_list = UserDefaults.manualConnections
}
var connectionsList: [Device] {
get {
return _list
}
}
func insert(device: Device) {
// Don't insert if already there
guard !_list.contains(where: {$0.id == device.id}) else {
return
}
// Add the new entry
var list = _list
list.append(device)
_list = list
UserDefaults.manualConnections = list
}
func updateDevice<T>(deviceId: UUID, key: WritableKeyPath<Device, T>, value: T) where T: Equatable {
var list = _list
if let deviceIndex = list.firstIndex(where: {$0.id == deviceId}) {
list[deviceIndex][keyPath: key] = value
_list = list
UserDefaults.manualConnections = list
}
}
func remove(device: Device) {
var list = _list
list.removeAll(where: {$0.id == device.id})
_list = list
UserDefaults.manualConnections = list
}
func remove(atOffsets: IndexSet) {
var list = _list
list.remove(atOffsets: atOffsets)
_list = list
UserDefaults.manualConnections = list
}
}

View file

@ -31,7 +31,7 @@ enum ConnectionEvent {
case disconnected(shouldReconnect: Bool)
}
enum ConnectionState: Equatable {
enum ConnectionState: Equatable, Codable {
case disconnected
case connecting
case connected

View file

@ -7,7 +7,8 @@
import Foundation
struct Device: Identifiable, Hashable {
struct Device: Identifiable, Hashable, Codable, CustomStringConvertible {
let id: UUID
var name: String
var transportType: TransportType
@ -23,10 +24,9 @@ struct Device: Identifiable, Hashable {
var connectionState: ConnectionState
var wasRestored: Bool = false
var isManualConnection: Bool = false
var connectionDetails: String?
init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, connectionDetails: String? = nil, wasRestored: Bool = false) {
init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, wasRestored: Bool = false, isManualConnection: Bool = false) {
self.id = id
self.name = name
self.transportType = transportType
@ -35,7 +35,7 @@ struct Device: Identifiable, Hashable {
self.rssi = rssi
self.num = num
self.wasRestored = wasRestored
self.connectionDetails = connectionDetails
self.isManualConnection = isManualConnection
}
var rssiString: String {
@ -57,4 +57,16 @@ struct Device: Identifiable, Hashable {
}
}
var description: String {
switch (shortName, longName) {
case (let shortName?, let longName?): // Both shortName and longName are non-nil
return "\(longName) (\(shortName))"
case (let shortName?, nil): // shortName is non-nil, longName is nil
return "\(shortName)"
case (nil, let longName?): // shortName is nil, longName is non-nil
return "\(longName)"
default: // Both are nil
return "Device(id: \(id))"
}
}
}

View file

@ -9,7 +9,7 @@ import Foundation
import CommonCrypto
import SwiftUI
enum TransportType: String, CaseIterable {
enum TransportType: String, CaseIterable, Codable {
case ble = "BLE"
case tcp = "TCP"
case serial = "Serial"
@ -53,14 +53,15 @@ protocol Transport {
var requiresPeriodicHeartbeat: Bool { get }
var supportsManualConnection: Bool { get }
func manuallyConnect(withConnectionString: String) async throws
func device(forManualConnection: String) -> Device?
func manuallyConnect(toDevice: Device) async throws
}
// Used to make stable-ish ID's for accessories that don't have a UUID
extension String {
func toUUIDFormatHash() -> UUID? {
func toUUIDFormatHash() -> UUID {
// Convert string to data
guard let data = self.data(using: .utf8) else { return nil }
let data = self.data(using: .utf8) ?? Data()
// Create buffer for SHA-256 hash (32 bytes)
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))

View file

@ -403,7 +403,11 @@ class BLETransport: Transport {
}
func manuallyConnect(withConnectionString: String) async throws {
func device(forManualConnection: String) -> Device? {
return nil
}
func manuallyConnect(toDevice: Device) async throws {
Logger.transport.error("🛜 [BLE] This transport does not support manual connections")
}

View file

@ -31,7 +31,7 @@ class SerialTransport: Transport {
while !Task.isCancelled {
let ports = self.getSerialPorts()
for port in ports {
let id = port.toUUIDFormatHash() ?? UUID()
let id = port.toUUIDFormatHash()
if !portsAlreadyNotified.contains(port) {
Logger.transport.info("🔱 [Serial] Port \(port, privacy: .public) found.")
let newDevice = Device(id: id,
@ -45,9 +45,8 @@ class SerialTransport: Transport {
for knownPort in portsAlreadyNotified where !ports.contains(knownPort) {
// Previosuly seen port is no longer available
Logger.transport.info("🔱 [Serial] Port \(knownPort, privacy: .public) is no longer connected.")
if let uuid = knownPort.toUUIDFormatHash() {
cont.yield(.deviceLost(uuid))
}
let uuid = knownPort.toUUIDFormatHash()
cont.yield(.deviceLost(uuid))
portsAlreadyNotified.removeAll(where: {$0 == knownPort})
}
try? await Task.sleep(for: .seconds(5))
@ -118,7 +117,11 @@ class SerialTransport: Transport {
return SerialConnection(path: device.identifier)
}
func manuallyConnect(withConnectionString: String) async throws {
func device(forManualConnection: String) -> Device? {
return nil
}
func manuallyConnect(toDevice: Device) async throws {
Logger.transport.error("🔱 [USB] This transport does not support manual connections")
}
}

View file

@ -73,7 +73,7 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe
let ip = service.ipv4Address ?? "Unknown IP"
// Use a mishmash of things and hash for stable? ID.
let idString = "\(service.name):\(host):\(ip):\(port)".toUUIDFormatHash() ?? UUID()
let idString = "\(service.name):\(host):\(ip):\(port)".toUUIDFormatHash()
// Save the resolved service locally for later
services[service.name] = ResolvedService(id: idString, service: service, host: host, port: port)
@ -97,8 +97,7 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe
let device = Device(id: idString,
name: name,
transportType: .tcp,
identifier: "\(host):\(port)",
connectionDetails: "\(ip):\(port)")
identifier: "\(host):\(port)")
continuation?.yield(.deviceFound(device))
}
@ -151,16 +150,56 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe
}
}
func manuallyConnect(withConnectionString: String) async throws {
let hashedIdentifier = withConnectionString.toUUIDFormatHash() ?? UUID()
let manualDevice = Device(id: hashedIdentifier,
name: "\(withConnectionString) (Manual)",
transportType: .tcp, identifier: withConnectionString)
try await AccessoryManager.shared.connect(to: manualDevice)
func device(forManualConnection connectionString: String) -> Device? {
let parts = connectionString.split(separator: ":")
var identifier: String
switch parts.count {
case 1:
// host & default port
identifier = "\(parts[0]):4403"
case 2:
// host & port
if parts[1].isValidTCPPort {
identifier = "\(parts[0]):\(parts[1])"
}
fallthrough
default:
return nil
}
let hashedIdentifier = identifier.toUUIDFormatHash()
return Device(id: hashedIdentifier,
name: "\(connectionString) (Manual)",
transportType: .tcp,
identifier: connectionString,
isManualConnection: true)
}
func manuallyConnect(toDevice device: Device) async throws {
try await AccessoryManager.shared.connect(to: device)
}
}
extension StringProtocol {
var isValidTCPPort: Bool {
// Check if the string is non-empty and contains only digits
guard !isEmpty, allSatisfy({ $0.isNumber }) else {
return false
}
// Parse the string to an integer
guard let port = Int(self) else {
return false // Fails if the string can't be converted to an integer
}
// Check if the port is in the valid TCP range (065535)
return port >= 0 && port <= 65535
}
}
extension NetService {
var ipv4Address: String? {
for addressData in addresses ?? [] {

View file

@ -23,4 +23,12 @@ extension Bundle {
public var isTestFlight: Bool {
return appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
}
public var isDebug: Bool {
#if DEBUG
return true
#else
return false
#endif
}
}

View file

@ -38,9 +38,8 @@ extension ChannelEntity {
}
func unreadMessages(context: NSManagedObjectContext) -> Int {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [] // sort is irrelvant.
fetchRequest.sortDescriptors = [] // sort is irrelevant.
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
return (try? context.count(for: fetchRequest)) ?? 0

View file

@ -38,4 +38,39 @@ extension MessageEntity {
}
return false // First message will have no timestamp
}
func relayDisplay() -> String? {
guard self.relayNode != 0 else { return nil }
let context = PersistenceController.shared.container.viewContext
let relaySuffix = Int64(self.relayNode & 0xFF)
let request: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
request.predicate = NSPredicate(format: "(num & 0xFF) == %lld", relaySuffix)
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 {
return String(format: "Node 0x%02X", UInt32(self.relayNode & 0xFF))
}
}
}

View file

@ -30,7 +30,7 @@ extension MyInfoEntity {
func unreadMessages(context: NSManagedObjectContext) -> Int {
// Returns the count of unread *channel* messages
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [] // sort is irrelvant.
fetchRequest.sortDescriptors = [] // sort is irrelevant.
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
return (try? context.count(for: fetchRequest)) ?? 0

View file

@ -56,7 +56,7 @@ extension UserEntity {
guard self.lastMessage != nil || skipLastMessageCheck else { return 0; }
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [] // sort is irrelvant.
fetchRequest.sortDescriptors = [] // sort is irrelevant.
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
return (try? context.count(for: fetchRequest)) ?? 0

View file

@ -79,6 +79,7 @@ extension UserDefaults {
case showDeviceOnboarding
case usageDataAndCrashReporting
case autoconnectOnDiscovery
case manualConnections
case testIntEnum
}
@ -178,6 +179,36 @@ extension UserDefaults {
@UserDefault(.testIntEnum, defaultValue: .one)
static var testIntEnum: TestIntEnum
static var manualConnections: [Device] {
get {
// Retrieve data from UserDefaults
guard let data = UserDefaults.standard.data(forKey: Keys.manualConnections.rawValue) else {
return []
}
// Decode the Data back to [Device]
do {
let decoder = JSONDecoder()
let devices = try decoder.decode([Device].self, from: data)
return devices
} catch {
return []
}
}
set {
do {
// Encode the [Device] to Data
let encoder = JSONEncoder()
let data = try encoder.encode(newValue)
// Store the Data in UserDefaults
UserDefaults.standard.set(data, forKey: Keys.manualConnections.rawValue)
} catch {
print("Failed to encode manualConnections: \(error)")
}
}
}
}
enum TestIntEnum: Int, Decodable {

View file

@ -624,6 +624,7 @@ func adminResponseAck (packet: MeshPacket, context: NSManagedObjectContext) {
fetchedMessage[0].ackError = Int32(RoutingError.none.rawValue)
fetchedMessage[0].receivedACK = true
fetchedMessage[0].realACK = true
fetchedMessage[0].relayNode = Int64(packet.relayNode)
fetchedMessage[0].ackSNR = packet.rxSnr
if fetchedMessage[0].fromUser != nil {
fetchedMessage[0].fromUser?.objectWillChange.send()
@ -699,9 +700,11 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana
fetchedMessage[0].realACK = true
}
}
fetchedMessage[0].relayNode = Int64(packet.relayNode)
fetchedMessage[0].ackError = Int32(routingMessage.errorReason.rawValue)
if routingMessage.errorReason == Routing.Error.none {
fetchedMessage[0].receivedACK = true
fetchedMessage[0].relays += 1
}
fetchedMessage[0].ackSNR = packet.rxSnr
@ -944,6 +947,9 @@ func textMessageAppPacket(
} else {
newMessage.messageTimestamp = Int32(Date().timeIntervalSince1970)
}
if packet.relayNode != 0 {
newMessage.relayNode = Int64(packet.relayNode)
}
newMessage.receivedACK = false
newMessage.snr = packet.rxSnr
newMessage.rssi = packet.rxRssi
@ -983,6 +989,7 @@ func textMessageAppPacket(
newMessage.pkiEncrypted = true
newMessage.publicKey = packet.publicKey
}
/// Check for key mismatch
if let nodeKey = newMessage.fromUser?.publicKey {
if newMessage.toUser != nil && packet.pkiEncrypted && !packet.publicKey.isEmpty {

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24299" systemVersion="25A354" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24299" systemVersion="25A362" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AmbientLightingConfigEntity" representedClassName="AmbientLightingConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="blue" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
@ -164,6 +164,8 @@
<attribute name="read" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="realACK" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="receivedACK" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="relayNode" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="relays" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="replyID" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>

View file

@ -465,13 +465,17 @@ func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext)
mutablePositions.add(position)
fetchedNode[0].id = Int64(packet.from)
fetchedNode[0].num = Int64(packet.from)
if positionMessage.time > 0 {
// Update the node's lastHeard.
// Some misconfigured nodes will broadcast position packets that claim GPS timestamps in the future. When updating lastHeard, don't use any future timestamps: fallback to using rxTime or Date() instead.
if positionMessage.time > 0 && (Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) <= Date()) {
fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time)))
} else if packet.rxTime > 0 {
fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime)))
} else {
fetchedNode[0].lastHeard = Date()
}
fetchedNode[0].snr = packet.rxSnr
fetchedNode[0].rssi = packet.rxRssi
fetchedNode[0].viaMqtt = packet.viaMqtt

View file

@ -26,8 +26,7 @@ struct Connect: View {
@State var isUnsetRegion = false
@State var invalidFirmwareVersion = false
@State var liveActivityStarted = false
@State var presentingSwitchPreferredPeripheral = false
@State var selectedPeripherialId = ""
@ObservedObject var manualConnections = ManualConnectionList.shared
var body: some View {
NavigationStack {
@ -64,22 +63,10 @@ struct Connect: View {
}
Text("Connection Name").font(.callout)+Text(": \(connectedDevice.name.addingVariationSelectors)")
.font(.callout).foregroundColor(Color.gray)
HStack {
HStack(alignment: .firstTextBaseline) {
TransportIcon(transportType: connectedDevice.transportType)
if connectedDevice.transportType == .ble {
// baseline aligned looks better for the signal meter
HStack(alignment: .firstTextBaseline) {
TransportIcon(transportType: connectedDevice.transportType)
connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) }
}
} else if connectedDevice.transportType == .tcp {
// Not baseline aligned looks better for the connection string
HStack {
TransportIcon(transportType: connectedDevice.transportType)
Text("\(connectedDevice.connectionDetails ?? connectedDevice.identifier)")
.foregroundColor(.gray)
}
} else {
TransportIcon(transportType: connectedDevice.transportType)
connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) }
}
Spacer()
}
@ -276,68 +263,33 @@ struct Connect: View {
.textCase(nil)
if !(accessoryManager.isConnected || accessoryManager .isConnecting) {
Section(header: HStack {
Text("Available Radios").font(.title)
Spacer()
ManualConnectionMenu()
}) {
ForEach(accessoryManager.devices.sorted(by: { $0.name < $1.name })) { device in
HStack {
if UserDefaults.preferredPeripheralId == device.id.uuidString {
Image(systemName: "star.fill")
.imageScale(.large).foregroundColor(.yellow)
.padding(.trailing)
} else {
Image(systemName: "circle.fill")
.imageScale(.large).foregroundColor(.gray)
.padding(.trailing)
}
VStack(alignment: .leading) {
Button(action: {
if UserDefaults.preferredPeripheralId.count > 0 && device.id.uuidString != UserDefaults.preferredPeripheralId {
if accessoryManager.allowDisconnect {
Task { try await accessoryManager.disconnect() }
}
presentingSwitchPreferredPeripheral = true
selectedPeripherialId = device.id.uuidString
} else {
Task {
try? await accessoryManager.connect(to: device)
}
}
}) {
Text(device.name).font(.callout)
}
// Show transport type
HStack {
TransportIcon(transportType: device.transportType)
if device.transportType == .tcp {
// Show IP and Port
Text("\(device.connectionDetails ?? device.identifier)")
.foregroundColor(.gray)
}
}
}
Spacer()
VStack {
device.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0) }
}
}.padding([.bottom, .top])
}
}
.confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) {
Button("Connect to new radio?", role: .destructive) {
UserDefaults.preferredPeripheralId = selectedPeripherialId
UserDefaults.preferredPeripheralNum = 0
if accessoryManager.allowDisconnect {
Task { try await accessoryManager.disconnect() }
Group {
Section(header: HStack {
Text("Available Radios").font(.title)
Spacer()
ManualConnectionMenu()
}) {
ForEach(accessoryManager.devices.sorted(by: { $0.name < $1.name })) { device in
DeviceConnectRow(device: device)
}
clearCoreDataDatabase(context: context, includeRoutes: false)
clearNotifications()
if let radio = accessoryManager.devices.first(where: { $0.id.uuidString == selectedPeripherialId }) {
Task {
try await accessoryManager.connect(to: radio)
}
if manualConnections.connectionsList.count > 0 {
Section(header: Text("Manual Connections").font(.title)) {
ForEach(manualConnections.connectionsList) { device in
DeviceConnectRow(device: device)
#if targetEnvironment(macCatalyst)
.contextMenu {
Button {
manualConnections.remove(device: device)
} label: {
Label("Delete", systemImage: "trash")
}
}
#endif
}.onDelete { offsets in
manualConnections.remove(atOffsets: offsets)
}
}
}
}
@ -491,6 +443,10 @@ struct TransportIcon: View {
}
struct ManualConnectionMenu: View {
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.managedObjectContext) var context
private struct IterableTransport: Identifiable {
let id: UUID
let icon: Image
@ -509,7 +465,9 @@ struct ManualConnectionMenu: View {
@State private var selectedTransport: IterableTransport?
@State private var showAlert: Bool = false
@State private var connectionString = ""
@State var presentingSwitchPreferredPeripheral = false
@State var deviceForManualConnection: Device?
var body: some View {
Menu {
ForEach(transports) { transport in
@ -539,11 +497,114 @@ struct ManualConnectionMenu: View {
Button("OK", action: {
if !connectionString.isEmpty {
Task {
try await selectedTransport.transport.manuallyConnect(withConnectionString: connectionString)
if let device = selectedTransport.transport.device(forManualConnection: connectionString) {
if UserDefaults.preferredPeripheralId == device.id.uuidString {
Task {
try await selectedTransport.transport.manuallyConnect(toDevice: device)
}
} else {
deviceForManualConnection = device
presentingSwitchPreferredPeripheral = true
}
}
}
})
}
}.confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) {
Button("Connect to new radio?", role: .destructive) {
if let device = deviceForManualConnection {
UserDefaults.preferredPeripheralId = device.id.uuidString
UserDefaults.preferredPeripheralNum = 0
if accessoryManager.allowDisconnect {
Task { try await accessoryManager.disconnect() }
}
clearCoreDataDatabase(context: context, includeRoutes: false)
clearNotifications()
Task {
try await selectedTransport?.transport.manuallyConnect(toDevice: device)
}
// Clean up just in case
deviceForManualConnection = nil
}
}
}
}
}
struct DeviceConnectRow: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var accessoryManager: AccessoryManager
@State var presentingSwitchPreferredPeripheral = false
let device: Device
var body: some View {
HStack {
if UserDefaults.preferredPeripheralId == device.id.uuidString {
Image(systemName: "star.fill")
.imageScale(.large).foregroundColor(.yellow)
.padding(.trailing)
} else {
Image(systemName: "circle.fill")
.imageScale(.large).foregroundColor(.gray)
.padding(.trailing)
}
VStack(alignment: .leading) {
Button(action: {
if UserDefaults.preferredPeripheralId.count > 0 && device.id.uuidString != UserDefaults.preferredPeripheralId {
if accessoryManager.allowDisconnect {
Task { try await accessoryManager.disconnect() }
}
presentingSwitchPreferredPeripheral = true
} else {
Task {
try? await accessoryManager.connect(to: device)
}
}
}) {
Text(device.name).font(.callout)
}
// Show transport type
#if !targetEnvironment(macCatalyst)
HStack(alignment: .center){
TransportIcon(transportType: device.transportType)
if device.isManualConnection && (device.longName != nil || device.shortName != nil) {
VStack (alignment: .leading) {
Text("Last seen device:")
Text("\(String(describing: device))")
}
}
}.padding(.top, 3.0)
#else
//Different alignment for Mac
HStack(alignment: .firstTextBaseline){
TransportIcon(transportType: device.transportType)
if device.isManualConnection && (device.longName != nil || device.shortName != nil) {
Text("Last seen device: \(String(describing: device))")
}
}
#endif
}
Spacer()
VStack {
device.getSignalStrength().map {
SignalStrengthIndicator(signalStrength: $0)
}
}
}.padding([.bottom, .top])
.confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) {
Button("Connect to new radio?", role: .destructive) {
UserDefaults.preferredPeripheralId = device.id.uuidString
UserDefaults.preferredPeripheralNum = 0
if accessoryManager.allowDisconnect {
Task { try await accessoryManager.disconnect() }
}
clearCoreDataDatabase(context: context, includeRoutes: false)
clearNotifications()
Task {
try await accessoryManager.connect(to: device)
}
}
}
}
}

View file

@ -11,6 +11,7 @@ struct MessageContextMenuItems: View {
let isCurrentUser: Bool
@Binding var isShowingDeleteConfirmation: Bool
let onReply: () -> Void
@State var relayDisplay: String? = nil
var body: some View {
VStack {
@ -19,6 +20,14 @@ struct MessageContextMenuItems: View {
}
Text("Channel") + Text(": \(message.channel)")
}
.onAppear {
DispatchQueue.global(qos: .userInitiated).async {
let result = message.relayDisplay()
DispatchQueue.main.async {
relayDisplay = result
}
}
}
Menu("Tapback") {
ForEach(Tapbacks.allCases) { tb in
@ -59,12 +68,27 @@ struct MessageContextMenuItems: View {
}
Menu("Message Details") {
// Precompute values to avoid executing non-View code inside the ViewBuilder
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp))
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
// Compute a relay display string if relayNode is present
VStack {
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))").foregroundColor(.gray)
Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))")
.foregroundColor(.gray)
}
if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) && message.fromUser?.userNode?.hopsAway ?? -1 == 0 {
if let relayDisplay {
let prefix = message.realACK ? "Ack Relay: " : "Relay: "
Text(prefix + relayDisplay)
.foregroundColor(relayDisplay.contains("Node ") ? .gray : .primary)
.font(relayDisplay.contains("Node ") ? .caption : .body)
}
if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) && message.fromUser?.userNode?.hopsAway ?? -1 == 0 {
VStack {
Text("SNR \(String(format: "%.2f", message.snr)) dB")
Text("RSSI \(String(format: "%.2f", message.rssi)) dBm")
@ -74,29 +98,29 @@ struct MessageContextMenuItems: View {
Text("Hops Away \(message.fromUser?.userNode?.hopsAway ?? 0)")
}
}
if message.relays != 0 && message.realACK == false {
Text("Relayed by \(message.relays) \(message.relays == 1 ? "node" : "nodes")")
}
if isCurrentUser && message.receivedACK {
VStack {
Text("Received Ack") + Text(": \(message.receivedACK ? "✔️" : "")")
Text("Recipient Ack") + Text(": \(message.realACK ? "✔️" : "")")
Text("Received Ack: \(message.receivedACK ? "✔️" : "")")
Text("Recipient Ack: \(message.realACK ? "✔️" : "")")
}
} else if isCurrentUser && message.ackError == 0 {
// Empty Error
Text("Waiting")
} else if isCurrentUser && message.ackError > 0 {
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
Text("\(ackErrorVal?.display ?? "Empty Ack Error")")
.fixedSize(horizontal: false, vertical: true)
}
if isCurrentUser {
VStack {
let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp))
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
if ackDate >= sixMonthsAgo! {
Text("Ack Time: \(ackDate.formattedDate(format: MessageText.timeFormatString))")
.foregroundColor(.gray)
}
if let sixMonthsAgo, ackDate >= sixMonthsAgo {
Text("Ack Time: \(ackDate.formattedDate(format: MessageText.timeFormatString))")
.foregroundColor(.gray)
}
}
if message.ackSNR != 0 {
VStack {
Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB")

View file

@ -47,7 +47,7 @@ struct MeshMapContent: MapContent {
@MapContentBuilder
var positionAnnotations: some MapContent {
ForEach(positions, id: \.id) { position in
/// Apply favorits filter and don't show ignored nodes
/// Apply favorites filter and don't show ignored nodes
if (!showFavorites || (position.nodePosition?.favorite == true)) && !(position.nodePosition?.ignored == true) {
let coordinateForNodePin: CLLocationCoordinate2D = if position.isPreciseLocation {
// Precise location: place node pin at actual location.