Meshtastic-Apple/Meshtastic/API/MeshtasticAPI.swift
Jake-B fc7d78f16b Made JSON structs private: everything CoreData now
DeviceHardware, FirmwareReleases, Releases, FirmwareRelease
2025-12-15 18:29:46 -05:00

454 lines
14 KiB
Swift

//
// MeshtasticAPI.swift
// Meshtastic
//
// Created by Jake Bordens on 12/4/25.
//
import Foundation
import OSLog
import SwiftUI
import CoreData
// These structs are public becase tehy are used elsewhere in the app to represent
// fields in the Core Data database.
enum ReleaseType: String {
case stable = "Stable"
case alpha = "Alpha"
case unlisted = "Unlisted"
}
enum Architecture: String, Codable, Identifiable {
case esp32 = "esp32"
case esp32C3 = "esp32-c3"
case esp32S3 = "esp32-s3"
case nrf52840 = "nrf52840"
case rp2040 = "rp2040"
case esp32C6 = "esp32-c6"
var id: String { rawValue }
}
// These structs are private because they are only used for decoding API responses.
// The rest of the app should be using Core Data entities.
private struct DeviceHardware: Codable {
let hwModel: Int
let hwModelSlug: String
let platformioTarget: String
let architecture: Architecture
let activelySupported: Bool
let displayName: String
let supportLevel: Int?
let tags: [String]?
let images: [String]?
let requiresDfu: Bool?
let hasInkHud: Bool?
let partitionScheme: String?
let hasMui: Bool?
}
/// Firmware Release Lists
private struct FirmwareReleases: Codable {
let releases: Releases
let pullRequests: [FirmwareRelease]
}
private struct Releases: Codable {
let stable, alpha: [FirmwareRelease]
}
private struct FirmwareRelease: Codable {
let id, title: String
let pageURL: String
let zipURL: String
let releaseNotes: String
enum CodingKeys: String, CodingKey {
case id, title
case pageURL = "page_url"
case zipURL = "zip_url"
case releaseNotes = "release_notes"
}
}
extension MeshtasticAPI {
enum MeshtasticAPIError: Error, LocalizedError {
case timedOut(TimeInterval)
case unableToRetreviveJSON
case unableToFindOrCreateEntity
case unknownArchitecture
case unknownPlatformIOTarget
var errorDescription: String? {
switch self {
case .timedOut(let seconds):
return "The operation timed out after \(seconds) seconds."
case .unableToRetreviveJSON:
return "Unable to retreive device hardware information."
case .unableToFindOrCreateEntity:
return "Unable to find or create Core Data entity."
case .unknownArchitecture:
return "Unknown architecture."
case .unknownPlatformIOTarget:
return "Unknown platformio target."
}
}
}
}
class MeshtasticAPI: ObservableObject, @unchecked Sendable {
// Singleton Access
static let shared = {
MeshtasticAPI(container: PersistenceController.shared.container)
}()
// MARK: - Constants
static let deviceURLEndpoint = URL(string: "https://api.meshtastic.org/resource/deviceHardware")!
static let imageURLPrefix = URL(string: "https://flasher.meshtastic.org/img/devices/")!
static let firmwareURLEndpoint = URL(string: "https://api.meshtastic.org/github/firmware/list")!
// MARK: - Private properties
private let fileManager = FileManager.default
private let decoder = JSONDecoder()
private let container: NSPersistentContainer
@Published var isLoadingDeviceList: Bool = false
@Published var isLoadingFirmwareList: Bool = false
private init(container: NSPersistentContainer) {
self.container = container
Task.detached {
try? await self.refreshDevicesAPIData()
try? await self.refreshFirmwareAPIData()
}
}
// MARK: - Main Logic
func refreshFirmwareAPIData() async throws {
await MainActor.run {
self.isLoadingFirmwareList = true
}
let apiData = try await Self.firmwareURLEndpoint.data(timeout: 5.0)
let decodedFirmware = try decoder.decode(FirmwareReleases.self, from: apiData)
let stableVersions = Set(decodedFirmware.releases.stable.map { $0.id })
let alphaVersions = Set(decodedFirmware.releases.alpha.map { $0.id })
await withTaskGroup(of: Void.self) { group in
for stableRelease in decodedFirmware.releases.stable {
group.addTask {
await self.processFirmware(release: stableRelease, releaseType: .stable)
}
}
for alphaRelease in decodedFirmware.releases.alpha {
group.addTask {
await self.processFirmware(release: alphaRelease, releaseType: .alpha)
}
}
}
// Anything that's left in stableVersions and alphaVersions is no longer present in the API and should be deleted.
let context = container.newBackgroundContext()
context.performAndWait {
let deleteRequest = FirmwareReleaseEntity.fetchRequest()
deleteRequest.predicate = Self.firmwareCompoundPredicate(stableVersions: stableVersions, alphaVersions: alphaVersions)
if let objectsToDelete = try? context.fetch(deleteRequest) {
for object in objectsToDelete {
Logger.services.info("Deleting orphaned firmware release: \(object.versionId ?? "unknown")")
context.delete(object)
}
}
}
// Save the deletions if any
if context.hasChanges {
try? context.save()
}
// Save the last update date for the firmware
UserDefaults.lastFirmwareAPIUpdate = Date()
await MainActor.run {
self.isLoadingFirmwareList = false
}
}
func refreshDevicesAPIData() async throws {
await MainActor.run {
self.isLoadingDeviceList = true
}
// PHASE 1: Network (Async) - Get the JSON first
var apiData: Data?
do {
apiData = try await Self.deviceURLEndpoint.data(timeout: 5.0)
} catch {
Logger.services.error("Unable to fetch device hardware from network: \(error.localizedDescription, privacy: .public)")
}
// Fallback to local bundle
if apiData == nil {
if let bundledJsonURL = Bundle.main.url(forResource: "DeviceHardware.json", withExtension: nil) {
apiData = try? Data(contentsOf: bundledJsonURL)
}
}
guard let finalData = apiData else {
throw MeshtasticAPIError.unableToRetreviveJSON
}
// Decode Swift Structs (Safe to do off the DB thread)
let decodedDevices = try decoder.decode([DeviceHardware].self, from: finalData)
// PHASE 2: Database (Sync) - Update Devices & Tags
let context = container.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// We will perform the bulk update and return a simple list of images we need to check next.
// We DO NOT do network calls for images inside this block.
try await context.perform {
// 1. Update Devices and Tags
for device in decodedDevices {
let fetchRequest = DeviceHardwareEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "platformioTarget == %@", device.platformioTarget)
fetchRequest.fetchLimit = 1
let existing = try? context.fetch(fetchRequest).first
let deviceEntity = existing ?? DeviceHardwareEntity(context: context)
// Update Properties
deviceEntity.hwModel = Int64(device.hwModel)
deviceEntity.hwModelSlug = device.hwModelSlug
deviceEntity.platformioTarget = device.platformioTarget
deviceEntity.architecture = device.architecture.rawValue
deviceEntity.activelySupported = device.activelySupported
deviceEntity.displayName = device.displayName
// Handle Tags (Helper function is now synchronous)
var tags = Set<DeviceHardwareTagEntity>()
if let tagList = device.tags {
for tagString in tagList {
// Safe because findOrCreateTag is synchronous and uses `context`
if let tagEntity = try? Self.findOrCreateTag(tag: tagString, context: context) {
tags.insert(tagEntity)
}
}
}
deviceEntity.tags = tags as NSSet
}
// 2. Cleanup Orphans
Self.deleteOrphanedTags(context: context)
// 3. Save Device Metadata
if context.hasChanges {
try context.save()
}
}
// PHASE 3: Images (Async Mixed)
// Now that the devices exist in DB, we process images one by one.
// We loop through the *Decoded Structs* (not DB objects) to get URLs.
await withTaskGroup(of: Void.self) { group in
for device in decodedDevices {
group.addTask {
guard let imagesList = device.images else { return }
for imageName in imagesList {
await self.processImage(imageName: imageName, platform: device.platformioTarget)
}
}
}
}
// Final cleanup of images (Sync)
try await context.perform {
Self.deleteOrphanedImages(context: context)
if context.hasChanges { try context.save() }
}
await MainActor.run {
self.isLoadingDeviceList = false
}
}
private func processFirmware(release: FirmwareRelease, releaseType: ReleaseType) async {
let context = container.newBackgroundContext()
await context.perform {
let fetchRequest = FirmwareReleaseEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "versionId == %@", release.id)
fetchRequest.fetchLimit = 1
let existingRelease = (try? context.fetch(fetchRequest).first) ?? FirmwareReleaseEntity(context: context)
existingRelease.versionId = release.id
existingRelease.title = release.title
existingRelease.releaseNotes = release.releaseNotes
existingRelease.pageUrl = release.pageURL
existingRelease.releaseType = releaseType.rawValue
let cleanString = release.id.hasPrefix("v") ? release.id.dropFirst() : Substring(release.id)
let parts = cleanString.split(separator: ".")
if parts.count >= 3 {
existingRelease.versionMajor = Int32(parts[0]) ?? 0
existingRelease.versionMinor = Int32(parts[1]) ?? 0
existingRelease.versionPatch = Int32(parts[2]) ?? 0
}
try? context.save()
Logger.services.info("Saving firmware release \(release.id) in database.")
}
}
/// Handles the logic of checking ETag -> Checking DB -> Downloading -> Bundle Fallback -> Saving
private func processImage(imageName: String, platform: String ) async {
let url = Self.imageURLPrefix.appendingPathComponent(imageName)
// 1. Network: Try to get ETag (Optional - might fail if offline or timeout)
let remoteETag = try? await url.eTag()
let context = container.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// 2. DB: Check if we already have this version or a usable cached version
let dbStatus: (isUpToDate: Bool, hasData: Bool) = await context.perform {
let request = DeviceHardwareImageEntity.fetchRequest()
request.predicate = NSPredicate(format: "fileName == %@", imageName)
request.fetchLimit = 1
if let existing = try? context.fetch(request).first,
let data = existing.svgData, !data.isEmpty {
// A: If we have a remote tag, does it match?
if let rTag = remoteETag {
return (existing.eTag == rTag, true)
}
// B: We are offline (no remote ETag), but we have data. Keep it.
return (true, true)
}
// No data in DB
return (false, false)
}
if dbStatus.isUpToDate {
Logger.services.debug("Image \(imageName) is up to date (or cached offline).")
return
}
// 3. Acquire Data (Network Primary -> Bundle Secondary)
var dataToSave: Data?
var eTagToSave: String?
// A: Attempt Network Download (only if we successfully got an ETag previously)
if let rTag = remoteETag {
if let networkData = try? await url.data(timeout: 5.0) {
dataToSave = networkData
eTagToSave = rTag
}
}
// B: Fallback to Bundle if Network failed or returned no data
if dataToSave == nil {
Logger.services.debug("Network unavailable or failed for \(imageName). Checking local bundle.")
// Look in the 'images' subdirectory
if let bundleURL = Bundle.main.url(forResource: imageName, withExtension: nil, subdirectory: "images"),
let bundleData = try? Data(contentsOf: bundleURL) {
dataToSave = bundleData
// We use "bundled" as a placeholder ETag.
// Next time the app runs with internet, "bundled" != "real_etag", forcing an update.
eTagToSave = "bundled"
}
}
// 4. DB: Save Image and Link to Device
guard let finalData = dataToSave, let finalETag = eTagToSave else {
Logger.services.error("Could not find image \(imageName) in Network or Bundle.")
return
}
await context.perform {
// Find the Device (we must fetch it in THIS context)
let deviceReq = DeviceHardwareEntity.fetchRequest()
deviceReq.predicate = NSPredicate(format: "platformioTarget == %@", platform)
guard let deviceEntity = try? context.fetch(deviceReq).first else { return }
// Find or Create Image Entity
let imageReq = DeviceHardwareImageEntity.fetchRequest()
imageReq.predicate = NSPredicate(format: "fileName == %@", imageName)
let existingImg = try? context.fetch(imageReq).first
let imageEntity = existingImg ?? DeviceHardwareImageEntity(context: context)
imageEntity.fileName = imageName
imageEntity.eTag = finalETag
imageEntity.svgData = finalData
// Create Relationship
deviceEntity.addToImages(imageEntity)
try? context.save()
Logger.services.info("Saving \(imageName) in database. eTag=\(finalETag)")
}
}
// MARK: - Helpers
// Removed @MainActor - this must run on the background context passed in
private static func findOrCreateTag(tag: String, context: NSManagedObjectContext) throws -> DeviceHardwareTagEntity {
let fetchRequest = DeviceHardwareTagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "tag == %@", tag)
fetchRequest.fetchLimit = 1
if let existingTag = try context.fetch(fetchRequest).first {
return existingTag
}
let newTag = DeviceHardwareTagEntity(context: context)
newTag.tag = tag
return newTag
}
private static func deleteOrphanedTags(context: NSManagedObjectContext) {
let req = DeviceHardwareTagEntity.fetchRequest()
req.predicate = NSPredicate(format: "devices.@count == 0")
if let tags = try? context.fetch(req) {
tags.forEach { context.delete($0) }
}
}
private static func deleteOrphanedImages(context: NSManagedObjectContext) {
let req = DeviceHardwareImageEntity.fetchRequest()
req.predicate = NSPredicate(format: "device == nil")
if let images = try? context.fetch(req) {
images.forEach { context.delete($0) }
}
}
// Helper to build compound predicate for firmware deletion (selects orphans)
static func firmwareCompoundPredicate(stableVersions: Set<String>, alphaVersions: Set<String>) -> NSPredicate {
let stablePredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "releaseType == %@", ReleaseType.stable.rawValue),
NSPredicate(format: "NOT (versionId IN %@)", stableVersions)
])
let alphaPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "releaseType == %@", ReleaseType.alpha.rawValue),
NSPredicate(format: "NOT (versionId IN %@)", alphaVersions)
])
return NSCompoundPredicate(orPredicateWithSubpredicates: [stablePredicate, alphaPredicate])
}
}
// Image Manifest Decoding
private struct ImageManifest: Codable {
let files: [String: [String: String]]
let api_hash: String
}