Skip to content

Commit be225b7

Browse files
committed
Merge remote-tracking branch 'origin/main' into test/geofence-alert-engine
# Conflicts: # Meshtastic.xcodeproj/project.pbxproj
2 parents 564ac5f + 6762575 commit be225b7

112 files changed

Lines changed: 34426 additions & 18797 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Localizable.xcstrings

Lines changed: 27979 additions & 18332 deletions
Large diffs are not rendered by default.

Meshtastic.xcodeproj/project.pbxproj

Lines changed: 89 additions & 0 deletions
Large diffs are not rendered by default.

Meshtastic/API/MeshtasticAPI.swift

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ class MeshtasticAPI: ObservableObject, @unchecked Sendable {
116116
static let deviceURLEndpoint = URL(string: "https://api.meshtastic.org/resource/deviceHardware")!
117117
static let imageURLPrefix = URL(string: "https://flasher.meshtastic.org/img/devices/")!
118118
static let firmwareURLEndpoint = URL(string: "https://api.meshtastic.org/github/firmware/list")!
119+
static let eventFirmwareURLEndpoint = URL(string: "https://api.meshtastic.org/resource/eventFirmware")!
119120

120121
// MARK: - Private properties
121122
private let fileManager = FileManager.default
@@ -132,10 +133,15 @@ class MeshtasticAPI: ObservableObject, @unchecked Sendable {
132133
// Load bundled catalog first — instant display, no network needed.
133134
try? await self.refreshBundledDevicesData()
134135
try? await self.refreshFirmwareAPIData()
136+
// Seed event-firmware branding from the bundle so it survives restarts offline.
137+
await self.refreshBundledEventFirmwareData()
135138
// Then silently update from the live API in the background.
136139
Task.detached(priority: .utility) {
137140
try? await self.refreshDevicesAPIData()
138141
}
142+
Task.detached(priority: .utility) {
143+
await self.refreshEventFirmwareAPIData()
144+
}
139145
}
140146
}
141147

@@ -632,3 +638,182 @@ extension MeshtasticAPI {
632638
return bundledData
633639
}
634640
}
641+
642+
// MARK: - Event Firmware Metadata
643+
644+
// Decoding structs for `GET https://api.meshtastic.org/resource/eventFirmware` (version 2).
645+
// Every field except `edition` is independently optional; a new event may ship with only a
646+
// subset populated, so all use `decodeIfPresent`.
647+
private struct EventFirmwareFile: Codable {
648+
let version: Int?
649+
let editions: [EventFirmwarePayload]
650+
}
651+
652+
private struct EventFirmwarePayload: Codable {
653+
let edition: String
654+
let displayName: String?
655+
let welcomeMessage: String?
656+
let tag: String?
657+
let eventStart: String?
658+
let eventEnd: String?
659+
let timeZone: String?
660+
let location: String?
661+
let iconUrl: String?
662+
let accentColor: String?
663+
let domain: String?
664+
let links: [EventFirmwareLinkPayload]?
665+
let theme: EventFirmwareThemePayload?
666+
let firmware: EventFirmwareBuildPayload?
667+
}
668+
669+
private struct EventFirmwareLinkPayload: Codable {
670+
let label: String
671+
let url: String
672+
}
673+
674+
private struct EventFirmwareThemePayload: Codable {
675+
let name: String?
676+
let tagline: String?
677+
let palette: [String]?
678+
let fonts: EventFirmwareFontsPayload?
679+
}
680+
681+
private struct EventFirmwareFontsPayload: Codable {
682+
let heading: String?
683+
let body: String?
684+
}
685+
686+
private struct EventFirmwareBuildPayload: Codable {
687+
let slug: String?
688+
let version: String?
689+
let id: String?
690+
let title: String?
691+
let zipUrl: String?
692+
let releaseNotes: String?
693+
}
694+
695+
extension MeshtasticAPI {
696+
697+
/// Load the bundled `event_firmware.json` seed into the cache. Runs at launch so event
698+
/// branding is available instantly and survives restarts offline (the live refresh then
699+
/// updates it in the background). Never throws — a missing/corrupt bundle is logged and
700+
/// leaves any existing cache intact.
701+
func refreshBundledEventFirmwareData() async {
702+
guard container != nil else { return }
703+
guard let bundledURL = Bundle.main.url(forResource: "event_firmware", withExtension: "json"),
704+
let data = try? Data(contentsOf: bundledURL),
705+
let decoded = try? decoder.decode(EventFirmwareFile.self, from: data) else {
706+
Logger.services.warning("Unable to load bundled event_firmware.json")
707+
return
708+
}
709+
// pruneMissing: false — the bundle is a *floor*, not authoritative. It must never delete
710+
// an edition that a prior successful live refresh cached but that isn't in the bundled
711+
// snapshot; that edition would otherwise vanish on every offline relaunch (the exact
712+
// poor-connectivity-at-the-venue scenario this cache exists for).
713+
await importEventEditions(decoded.editions, pruneMissing: false)
714+
Logger.services.info("Loaded bundled event firmware (\(decoded.editions.count, privacy: .public) editions)")
715+
}
716+
717+
/// Silently refresh the event-firmware metadata from the live API.
718+
///
719+
/// IMPORTANT: this deliberately does NOT use the short `data(timeout:)` deadline the device
720+
/// and firmware calls use. `api.meshtastic.org` is measured at 20–60s for this resource; a
721+
/// short deadline cancels every refresh and pins users to the bundled seed (Android hit
722+
/// exactly this and removed its local deadline). We rely on the default URLSession timeout
723+
/// plus stale-while-revalidate. An empty or failed response is a **no-op** — it must leave
724+
/// the existing cache intact, never wipe it.
725+
func refreshEventFirmwareAPIData() async {
726+
guard container != nil else { return }
727+
728+
var request = URLRequest(url: Self.eventFirmwareURLEndpoint)
729+
// No short local timeout — see the doc comment above. Revalidate against the server
730+
// cache so a 304 is cheap while still surfacing new events.
731+
request.cachePolicy = .reloadRevalidatingCacheData
732+
733+
guard let (data, response) = try? await URLSession.shared.data(for: request),
734+
let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
735+
!data.isEmpty else {
736+
Logger.services.warning("Event firmware API fetch failed or empty — keeping cached editions")
737+
return
738+
}
739+
740+
guard let decoded = try? decoder.decode(EventFirmwareFile.self, from: data),
741+
!decoded.editions.isEmpty else {
742+
// A decode failure or an editions-less payload must not clobber the cache.
743+
Logger.services.warning("Event firmware API returned no usable editions — keeping cache")
744+
return
745+
}
746+
747+
// pruneMissing: true — the live API is authoritative, so an edition it no longer lists is
748+
// genuinely retired and should be removed from the cache.
749+
await importEventEditions(decoded.editions, pruneMissing: true)
750+
UserDefaults.lastEventFirmwareAPIUpdate = Date()
751+
Logger.services.info("Refreshed event firmware from API (\(decoded.editions.count, privacy: .public) editions)")
752+
}
753+
754+
/// Upsert the given editions into `EventFirmwareEntity`. When `pruneMissing` is true, also
755+
/// delete rows not present in `editions` — this is reserved for the **authoritative** live-API
756+
/// import. The bundled seed passes `pruneMissing: false` so it only upserts/seeds and never
757+
/// deletes an edition cached from a prior successful API refresh. Called with a non-empty
758+
/// list from both paths; the caller guarantees an empty/failed fetch never reaches here.
759+
private func importEventEditions(_ editions: [EventFirmwarePayload], pruneMissing: Bool) async {
760+
guard let container else { return }
761+
await MainActor.run {
762+
let context = container.mainContext
763+
let importedKeys = Set(editions.map { $0.edition })
764+
765+
for payload in editions {
766+
let key = payload.edition
767+
var descriptor = FetchDescriptor<EventFirmwareEntity>(
768+
predicate: #Predicate { $0.edition == key }
769+
)
770+
descriptor.fetchLimit = 1
771+
772+
let entity: EventFirmwareEntity
773+
if let existing = try? context.fetch(descriptor).first {
774+
entity = existing
775+
} else {
776+
entity = EventFirmwareEntity(edition: key)
777+
context.insert(entity)
778+
}
779+
780+
entity.displayName = payload.displayName
781+
entity.welcomeMessage = payload.welcomeMessage
782+
entity.tag = payload.tag
783+
entity.eventStart = payload.eventStart
784+
entity.eventEnd = payload.eventEnd
785+
entity.timeZone = payload.timeZone
786+
entity.location = payload.location
787+
entity.iconUrl = payload.iconUrl
788+
entity.accentColor = payload.accentColor
789+
entity.domain = payload.domain
790+
entity.setLinks((payload.links ?? []).map {
791+
EventFirmwareEntity.Link(label: $0.label, url: $0.url)
792+
})
793+
entity.themeName = payload.theme?.name
794+
entity.themeTagline = payload.theme?.tagline
795+
entity.themePalette = payload.theme?.palette ?? []
796+
entity.themeFontHeading = payload.theme?.fonts?.heading
797+
entity.themeFontBody = payload.theme?.fonts?.body
798+
entity.firmwareSlug = payload.firmware?.slug
799+
entity.firmwareVersion = payload.firmware?.version
800+
entity.firmwareId = payload.firmware?.id
801+
entity.firmwareTitle = payload.firmware?.title
802+
entity.firmwareZipUrl = payload.firmware?.zipUrl
803+
entity.firmwareReleaseNotes = payload.firmware?.releaseNotes
804+
}
805+
806+
// Delete editions no longer present — only for the authoritative live-API import.
807+
if pruneMissing {
808+
let allDescriptor = FetchDescriptor<EventFirmwareEntity>()
809+
if let all = try? context.fetch(allDescriptor) {
810+
for row in all where !importedKeys.contains(row.edition) {
811+
context.delete(row)
812+
}
813+
}
814+
}
815+
816+
try? context.save()
817+
}
818+
}
819+
}

Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ import MeshtasticProtobufs
1111
import CoreLocation
1212

1313
extension AccessoryManager {
14+
nonisolated static func locationProviderSleepSeconds(configuredInterval: Int) -> Int {
15+
max(5, configuredInterval)
16+
}
17+
1418
func initializeLocationProvider() {
1519
self.locationTask = Task {
1620
repeat {
17-
try? await Task.sleep(for: .seconds(30)) // sleep for 30 seconds. This throws if task is cancelled
21+
let sleepSeconds = Self.locationProviderSleepSeconds(configuredInterval: UserDefaults.provideLocationInterval)
22+
try? await Task.sleep(for: .seconds(sleepSeconds)) // Throws if task is cancelled
1823

1924
guard let fromNodeNum = activeConnection?.device.num else {
2025
return
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "chirpy-crouch.png",
5+
"idiom" : "universal",
6+
"scale" : "1x"
7+
},
8+
{
9+
"idiom" : "universal",
10+
"scale" : "2x"
11+
},
12+
{
13+
"idiom" : "universal",
14+
"scale" : "3x"
15+
}
16+
],
17+
"info" : {
18+
"author" : "xcode",
19+
"version" : 1
20+
}
21+
}
125 KB
Loading
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "chirpy-idle.png",
5+
"idiom" : "universal",
6+
"scale" : "1x"
7+
},
8+
{
9+
"idiom" : "universal",
10+
"scale" : "2x"
11+
},
12+
{
13+
"idiom" : "universal",
14+
"scale" : "3x"
15+
}
16+
],
17+
"info" : {
18+
"author" : "xcode",
19+
"version" : 1
20+
}
21+
}
108 KB
Loading
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "chirpy-jump.png",
5+
"idiom" : "universal",
6+
"scale" : "1x"
7+
},
8+
{
9+
"idiom" : "universal",
10+
"scale" : "2x"
11+
},
12+
{
13+
"idiom" : "universal",
14+
"scale" : "3x"
15+
}
16+
],
17+
"info" : {
18+
"author" : "xcode",
19+
"version" : 1
20+
}
21+
}
139 KB
Loading

0 commit comments

Comments
 (0)