Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Meshtastic.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,10 @@
DD000011UTILENTST0000001 /* UtilityAndEnumTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD000011UTILENTST0000000 /* UtilityAndEnumTests.swift */; };
DD000012COTEXTTST0000001 /* CoTExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD000012COTEXTTST0000000 /* CoTExtensionTests.swift */; };
DD000013FWAPITST00000001 /* FirmwareAndAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD000013FWAPITST00000000 /* FirmwareAndAPITests.swift */; };
DD000031FWUPNPOL0000001 /* FirmwareUpdateNotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD000031FWUPNPOL0000000 /* FirmwareUpdateNotificationPolicy.swift */; };
DD000032FWUPNTFRSRC001 /* FirmwareUpdateNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD000032FWUPNTFRSRC000 /* FirmwareUpdateNotifier.swift */; };
DD000032FWUPNTFR0000001 /* FirmwareUpdateNotifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD000032FWUPNTFR0000000 /* FirmwareUpdateNotifierTests.swift */; };
DD000031FWUPNTST0000001 /* FirmwareUpdateNotificationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD000031FWUPNTST0000000 /* FirmwareUpdateNotificationPolicyTests.swift */; };
DD000014RTRDLTST00000001 /* RouterDeepLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD000014RTRDLTST00000000 /* RouterDeepLinkTests.swift */; };
DD000016NAVSTATST00000001 /* NavigationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD000016NAVSTATST00000000 /* NavigationStateTests.swift */; };
DD000017MTRVZTST00000001 /* MetricsVisualizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD000017MTRVZTST00000000 /* MetricsVisualizationTests.swift */; };
Expand Down Expand Up @@ -889,6 +893,10 @@
DD000011UTILENTST0000000 /* UtilityAndEnumTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilityAndEnumTests.swift; sourceTree = "<group>"; };
DD000012COTEXTTST0000000 /* CoTExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoTExtensionTests.swift; sourceTree = "<group>"; };
DD000013FWAPITST00000000 /* FirmwareAndAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareAndAPITests.swift; sourceTree = "<group>"; };
DD000031FWUPNPOL0000000 /* FirmwareUpdateNotificationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareUpdateNotificationPolicy.swift; sourceTree = "<group>"; };
DD000032FWUPNTFRSRC000 /* FirmwareUpdateNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareUpdateNotifier.swift; sourceTree = "<group>"; };
DD000032FWUPNTFR0000000 /* FirmwareUpdateNotifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareUpdateNotifierTests.swift; sourceTree = "<group>"; };
DD000031FWUPNTST0000000 /* FirmwareUpdateNotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareUpdateNotificationPolicyTests.swift; sourceTree = "<group>"; };
DD000014RTRDLTST00000000 /* RouterDeepLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterDeepLinkTests.swift; sourceTree = "<group>"; };
DD000016NAVSTATST00000000 /* NavigationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStateTests.swift; sourceTree = "<group>"; };
DD000017MTRVZTST00000000 /* MetricsVisualizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsVisualizationTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1326,6 +1334,8 @@
children = (
23C2BE292EEAF96A00F6A997 /* FirmwareViewModel.swift */,
2315D1A42EED94E800E0FAE7 /* FirmwareFile.swift */,
DD000031FWUPNPOL0000000 /* FirmwareUpdateNotificationPolicy.swift */,
DD000032FWUPNTFRSRC000 /* FirmwareUpdateNotifier.swift */,
);
path = Firmware;
sourceTree = "<group>";
Expand Down Expand Up @@ -1626,6 +1636,8 @@
DD000011UTILENTST0000000 /* UtilityAndEnumTests.swift */,
DD000012COTEXTTST0000000 /* CoTExtensionTests.swift */,
DD000013FWAPITST00000000 /* FirmwareAndAPITests.swift */,
DD000031FWUPNTST0000000 /* FirmwareUpdateNotificationPolicyTests.swift */,
DD000032FWUPNTFR0000000 /* FirmwareUpdateNotifierTests.swift */,
DD000014RTRDLTST00000000 /* RouterDeepLinkTests.swift */,
DD000016NAVSTATST00000000 /* NavigationStateTests.swift */,
DD000017MTRVZTST00000000 /* MetricsVisualizationTests.swift */,
Expand Down Expand Up @@ -2763,6 +2775,8 @@
DD000011UTILENTST0000001 /* UtilityAndEnumTests.swift in Sources */,
DD000012COTEXTTST0000001 /* CoTExtensionTests.swift in Sources */,
DD000013FWAPITST00000001 /* FirmwareAndAPITests.swift in Sources */,
DD000031FWUPNTST0000001 /* FirmwareUpdateNotificationPolicyTests.swift in Sources */,
DD000032FWUPNTFR0000001 /* FirmwareUpdateNotifierTests.swift in Sources */,
DD000014RTRDLTST00000001 /* RouterDeepLinkTests.swift in Sources */,
DD000016NAVSTATST00000001 /* NavigationStateTests.swift in Sources */,
DD000017MTRVZTST00000001 /* MetricsVisualizationTests.swift in Sources */,
Expand Down Expand Up @@ -2904,6 +2918,8 @@
DDDC22382BA92344002C44F1 /* MeshMapContent.swift in Sources */,
230A98422EF86AA9004D87F1 /* ESP32BLEOTAViewModel.swift in Sources */,
23C2BE2A2EEAF96A00F6A997 /* FirmwareViewModel.swift in Sources */,
DD000031FWUPNPOL0000001 /* FirmwareUpdateNotificationPolicy.swift in Sources */,
DD000032FWUPNTFRSRC001 /* FirmwareUpdateNotifier.swift in Sources */,
DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */,
6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */,
DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ extension AccessoryManager {
if let activeDevice = self.activeConnection?.device, activeDevice.isManualConnection {
ManualConnectionList.shared.insert(device: activeDevice)
}

try await FirmwareUpdateNotifier.notifyIfNeeded(accessoryManager: self)
}

// Step 8: Update UI and status to connected
Expand Down
14 changes: 14 additions & 0 deletions Meshtastic/Extensions/UserDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ extension UserDefaults {
case testIntEnum
case lastDeviceAPIUpdate
case lastFirmwareAPIUpdate
case firmwareUpdateNotificationKeys
}

func reset() {
Expand Down Expand Up @@ -194,6 +195,19 @@ extension UserDefaults {

@UserDefault(.testIntEnum, defaultValue: .one)
static var testIntEnum: TestIntEnum

@UserDefault(.firmwareUpdateNotificationKeys, defaultValue: [])
static var firmwareUpdateNotificationKeys: [String]

static var firmwareUpdateNotificationKeySet: Set<String> {
Set(firmwareUpdateNotificationKeys)
}

static func recordFirmwareUpdateNotificationKey(_ key: String) {
var keys = firmwareUpdateNotificationKeySet
keys.insert(key)
firmwareUpdateNotificationKeys = keys.sorted()
}

static var manualConnections: [Device] {
get {
Expand Down
64 changes: 64 additions & 0 deletions Meshtastic/Model/Firmware/FirmwareUpdateNotificationPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// MARK: - FirmwareUpdateNotificationPolicy

import Foundation

enum FirmwareUpdateInstallMethod: Equatable {
case appOTA
case flasher
}

enum FirmwareUpdateNotificationPolicy {
static func installMethod(architecture: String?) -> FirmwareUpdateInstallMethod {
guard let architecture = architecture.flatMap({ Architecture(rawValue: $0) }) else {
return .flasher
}

switch architecture {
case .esp32, .esp32C3, .esp32S3, .esp32C6, .nrf52840:
return .appOTA
case .rp2040:
return .flasher
}
}

static func normalizedVersion(_ version: String) -> String {
let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanVersion = trimmedVersion.hasPrefix("v") ? String(trimmedVersion.dropFirst()) : trimmedVersion
let parts = cleanVersion.split(separator: ".")
guard parts.count >= 3 else { return cleanVersion }
return parts.prefix(3).joined(separator: ".")
}

static func isUpdateAvailable(current: String, latestStable: String) -> Bool {
let currentVersion = normalizedVersion(current)
let latestStableVersion = normalizedVersion(latestStable)
return currentVersion.compare(latestStableVersion, options: .numeric) == .orderedAscending
}

static func notificationKey(
nodeNum: Int64,
platformioTarget: String,
latestStableVersion: String
) -> String {
"firmware-update-notified:\(nodeNum):\(platformioTarget):\(normalizedVersion(latestStableVersion))"
}

static func shouldNotify(
nodeNum: Int64,
platformioTarget: String,
currentVersion: String,
latestStableVersion: String,
alreadyNotified: Set<String>
) -> Bool {
guard isUpdateAvailable(current: currentVersion, latestStable: latestStableVersion) else {
return false
}

let key = notificationKey(
nodeNum: nodeNum,
platformioTarget: platformioTarget,
latestStableVersion: latestStableVersion
)
return !alreadyNotified.contains(key)
}
}
211 changes: 211 additions & 0 deletions Meshtastic/Model/Firmware/FirmwareUpdateNotifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// MARK: - FirmwareUpdateNotifier

import Foundation
import OSLog
import SwiftData

struct FirmwareUpdateNotificationCandidate {
let nodeNum: Int64
let deviceName: String?
let platformioTarget: String?
let installMethod: FirmwareUpdateInstallMethod
let currentVersion: String?
let latestStableVersion: String?
}

struct FirmwareUpdateNotificationSource {
let nodeNum: Int64
let deviceName: String?
let platformioTarget: String?
let architecture: String?
let metadataVersion: String?
let connectedVersion: String?
let latestStableVersion: String?
}

struct FirmwareUpdateNotice: Equatable {
let notificationKey: String
let deviceName: String
let currentVersion: String
let latestStableVersion: String
let installMethod: FirmwareUpdateInstallMethod

var notificationContent: String {
switch installMethod {
case .appOTA:
return "\(deviceName) is running \(currentVersion). Stable \(latestStableVersion) is available in Firmware Updates."
case .flasher:
return "\(deviceName) is running \(currentVersion). Stable \(latestStableVersion) is available. Use Meshtastic Flasher to update this hardware."
}
}

var connectMessage: String {
switch installMethod {
case .appOTA:
return "\(currentVersion) is behind stable \(latestStableVersion). Open Firmware Updates when you're ready."
case .flasher:
return "\(currentVersion) is behind stable \(latestStableVersion). Use Meshtastic Flasher for this hardware."
}
}
}

enum FirmwareUpdateNotifier {
static let target = "firmwareUpdates"
static let path = "meshtastic:///settings/firmwareUpdates"
private static let staleFirmwareAPIInterval: TimeInterval = 24 * 60 * 60

static func candidate(from source: FirmwareUpdateNotificationSource) -> FirmwareUpdateNotificationCandidate {
FirmwareUpdateNotificationCandidate(
nodeNum: source.nodeNum,
deviceName: source.deviceName,
platformioTarget: source.platformioTarget,
installMethod: FirmwareUpdateNotificationPolicy.installMethod(architecture: source.architecture),
currentVersion: source.metadataVersion?.isEmpty == false ? source.metadataVersion : source.connectedVersion,
latestStableVersion: source.latestStableVersion
)
}

static func notice(for candidate: FirmwareUpdateNotificationCandidate) -> FirmwareUpdateNotice? {
guard let platformioTarget = candidate.platformioTarget,
let currentVersion = candidate.currentVersion,
let latestStableVersion = candidate.latestStableVersion,
FirmwareUpdateNotificationPolicy.isUpdateAvailable(current: currentVersion, latestStable: latestStableVersion) else {
return nil
}

let key = FirmwareUpdateNotificationPolicy.notificationKey(
nodeNum: candidate.nodeNum,
platformioTarget: platformioTarget,
latestStableVersion: latestStableVersion
)
let displayName: String
if let trimmedName = candidate.deviceName?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmedName.isEmpty {
displayName = trimmedName
} else {
displayName = "Connected node"
}
let current = FirmwareUpdateNotificationPolicy.normalizedVersion(currentVersion)
let latest = FirmwareUpdateNotificationPolicy.normalizedVersion(latestStableVersion)

return FirmwareUpdateNotice(
notificationKey: key,
deviceName: displayName,
currentVersion: current,
latestStableVersion: latest,
installMethod: candidate.installMethod
)
}

static func notification(
for candidate: FirmwareUpdateNotificationCandidate,
alreadyNotified: Set<String>
) -> Notification? {
guard let notice = notice(for: candidate),
!alreadyNotified.contains(notice.notificationKey) else {
return nil
}

return Notification(
id: notice.notificationKey,
title: "Firmware update available",
subtitle: notice.deviceName,
content: notice.notificationContent,
target: target,
path: path
)
}

@MainActor
static func notifyIfNeeded(accessoryManager: AccessoryManager) async throws {
await refreshFirmwareDataIfStale()
try Task.checkCancellation()

guard let candidate = candidate(accessoryManager: accessoryManager),
let notification = notification(
for: candidate,
alreadyNotified: UserDefaults.firmwareUpdateNotificationKeySet
) else {
return
}
try Task.checkCancellation()

let localNotificationManager = LocalNotificationManager()
localNotificationManager.notifications = [notification]
localNotificationManager.schedule()
UserDefaults.recordFirmwareUpdateNotificationKey(notification.id)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@MainActor
static func notice(accessoryManager: AccessoryManager) -> FirmwareUpdateNotice? {
guard let candidate = candidate(accessoryManager: accessoryManager) else { return nil }
return notice(for: candidate)
}

@MainActor
private static func refreshFirmwareDataIfStale() async {
guard UserDefaults.lastFirmwareAPIUpdate == .distantPast
|| abs(UserDefaults.lastFirmwareAPIUpdate.timeIntervalSinceNow) > staleFirmwareAPIInterval else {
return
}

do {
try await MeshtasticAPI.shared.refreshFirmwareAPIData()
} catch {
Logger.services.warning("Failed to refresh firmware data before update notification check: \(error.localizedDescription, privacy: .public)")
}
}

@MainActor
private static func candidate(accessoryManager: AccessoryManager) -> FirmwareUpdateNotificationCandidate? {
guard let nodeNum = accessoryManager.activeDeviceNum,
let node = getNodeInfo(id: nodeNum, context: accessoryManager.context),
let platformioTarget = node.myInfo?.pioEnv,
let hardware = hardware(platformioTarget: platformioTarget, context: accessoryManager.context) else {
return nil
}

return candidate(from: FirmwareUpdateNotificationSource(
nodeNum: node.num,
deviceName: node.user?.longName ?? accessoryManager.activeConnection?.device.longName ?? accessoryManager.activeConnection?.device.name,
platformioTarget: platformioTarget,
architecture: hardware.architecture,
metadataVersion: node.metadata?.firmwareVersion,
connectedVersion: accessoryManager.connectedVersion,
latestStableVersion: latestStableFirmwareVersion(context: accessoryManager.context)
))
}

@MainActor
private static func hardware(platformioTarget: String, context: ModelContext) -> DeviceHardwareEntity? {
var descriptor = FetchDescriptor<DeviceHardwareEntity>(
predicate: #Predicate { $0.platformioTarget == platformioTarget }
)
descriptor.fetchLimit = 1
do {
return try context.fetch(descriptor).first
} catch {
Logger.services.warning("Failed to fetch hardware for firmware update notification target \(platformioTarget, privacy: .public): \(error.localizedDescription, privacy: .public)")
return nil
}
}

@MainActor
private static func latestStableFirmwareVersion(context: ModelContext) -> String? {
let stableRawValue = ReleaseType.stable.rawValue
var descriptor = FetchDescriptor<FirmwareReleaseEntity>(
predicate: #Predicate { $0.releaseType == stableRawValue },
sortBy: [
SortDescriptor(\.versionMajor, order: .reverse),
SortDescriptor(\.versionMinor, order: .reverse),
SortDescriptor(\.versionPatch, order: .reverse)
]
)
descriptor.fetchLimit = 1
do {
return try context.fetch(descriptor).first?.versionId
} catch {
Logger.services.warning("Failed to fetch latest stable firmware release for update notification: \(error.localizedDescription, privacy: .public)")
return nil
}
}
}
Loading
Loading