Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,10 @@ extension AccessoryManager {
if let activeDevice = self.activeConnection?.device, activeDevice.isManualConnection {
ManualConnectionList.shared.insert(device: activeDevice)
}

Task { @MainActor in
await FirmwareUpdateNotifier.notifyIfNeeded(accessoryManager: self)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// 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
59 changes: 59 additions & 0 deletions Meshtastic/Model/Firmware/FirmwareUpdateNotificationPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// MARK: - FirmwareUpdateNotificationPolicy

import Foundation

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

switch architecture {
case .esp32, .esp32C3, .esp32S3, .esp32C6, .nrf52840:
return true
case .rp2040:
return false
}
}

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)
}
}
159 changes: 159 additions & 0 deletions Meshtastic/Model/Firmware/FirmwareUpdateNotifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// MARK: - FirmwareUpdateNotifier

import Foundation
import OSLog
import SwiftData

struct FirmwareUpdateNotificationCandidate {
let nodeNum: Int64
let deviceName: String?
let platformioTarget: String?
let supportsAppOTA: Bool
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?
}

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,
supportsAppOTA: FirmwareUpdateNotificationPolicy.supportsAppOTA(architecture: source.architecture),
currentVersion: source.metadataVersion?.isEmpty == false ? source.metadataVersion : source.connectedVersion,
latestStableVersion: source.latestStableVersion
)
}

static func notification(
for candidate: FirmwareUpdateNotificationCandidate,
alreadyNotified: Set<String>
) -> Notification? {
guard let platformioTarget = candidate.platformioTarget,
candidate.supportsAppOTA,
let currentVersion = candidate.currentVersion,
let latestStableVersion = candidate.latestStableVersion,
FirmwareUpdateNotificationPolicy.shouldNotify(
nodeNum: candidate.nodeNum,
platformioTarget: platformioTarget,
currentVersion: currentVersion,
latestStableVersion: latestStableVersion,
alreadyNotified: alreadyNotified
) 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 Notification(
id: key,
title: "Firmware update available",
subtitle: displayName,
content: "\(displayName) is running \(current). Stable \(latest) is available.",
target: target,
path: path
)
}

@MainActor
static func notifyIfNeeded(accessoryManager: AccessoryManager) async {
await refreshFirmwareDataIfStale()

guard let nodeNum = accessoryManager.activeDeviceNum,
let node = getNodeInfo(id: nodeNum, context: accessoryManager.context),
let platformioTarget = node.myInfo?.pioEnv,
let hardware = hardwareSupportingAppOTA(platformioTarget: platformioTarget, context: accessoryManager.context) else {
return
}

let candidate = 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)
))

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

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

@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 hardwareSupportingAppOTA(platformioTarget: String, context: ModelContext) -> DeviceHardwareEntity? {
var descriptor = FetchDescriptor<DeviceHardwareEntity>(
predicate: #Predicate { $0.platformioTarget == platformioTarget }
)
descriptor.fetchLimit = 1
guard let hardware = try? context.fetch(descriptor).first,
FirmwareUpdateNotificationPolicy.supportsAppOTA(architecture: hardware.architecture) else {
return nil
}
return hardware
}

@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
return try? context.fetch(descriptor).first?.versionId
}
}
30 changes: 15 additions & 15 deletions Meshtastic/Resources/docs/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,35 +125,35 @@
"firmware",
"update",
"radio",
"updates",
"app",
"ble",
"updates",
"version",
"stable",
"not",
"hardware",
"ble",
"settings",
"progress",
"over",
"not",
"connected",
"channel",
"usually",
"troubleshooting",
"transfer",
"stable",
"running",
"release",
"ota",
"older",
"notification",
"node",
"meshtastic",
"list",
"hardware",
"latest",
"keep",
"failed",
"during",
"description",
"check",
"bluetooth",
"binary",
"available",
"after",
"within",
"way"
"during"
],
"charCount": 1810
"charCount": 2217
},
{
"id": "getting-started",
Expand Down
4 changes: 4 additions & 0 deletions Meshtastic/Resources/docs/markdown/user/firmware.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ The app can check for and install Meshtastic firmware updates directly on your c
2. Go to **Settings → Firmware Updates**.
3. The app shows the firmware version currently running on your radio and the latest stable release available from GitHub.

When you connect to a node running firmware older than the latest stable release, the app can send a firmware update notification. Tapping the notification opens **Firmware Updates** so you can review and start the update for hardware that supports app-based OTA.

The app remembers each node, hardware target, and stable version it has already notified you about, so it will not keep sending the same reminder.

## Installing an Update

1. Tap **Update Firmware** when a newer version is available.
Expand Down
2 changes: 2 additions & 0 deletions Meshtastic/Resources/docs/user/firmware.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ <h2>Checking for Updates</h2>
<li>Go to <strong>Settings → Firmware Updates</strong>.</li>
<li>The app shows the firmware version currently running on your radio and the latest stable release available from GitHub.</li>
</ol>
<p>When you connect to a node running firmware older than the latest stable release, the app can send a firmware update notification. Tapping the notification opens <strong>Firmware Updates</strong> so you can review and start the update for hardware that supports app-based OTA.</p>
<p>The app remembers each node, hardware target, and stable version it has already notified you about, so it will not keep sending the same reminder.</p>
<h2>Installing an Update</h2>
<ol>
<li>Tap <strong>Update Firmware</strong> when a newer version is available.</li>
Expand Down
Loading
Loading