diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml
index 9ada55343..053a23fa5 100644
--- a/.github/workflows/scheduled-updates.yml
+++ b/.github/workflows/scheduled-updates.yml
@@ -79,6 +79,35 @@ jobs:
fi
fi
+ - name: Update event firmware metadata
+ id: event_firmware
+ run: |
+ event_firmware_file_path="androidApp/src/main/assets/event_firmware.json"
+ temp_event_firmware_file="/tmp/new_event_firmware.json"
+
+ echo "Fetching latest event firmware metadata..."
+ http_code=$(curl -s --max-time 90 -o "$temp_event_firmware_file" -w '%{http_code}' https://api.meshtastic.org/resource/eventFirmware || true)
+ http_code="${http_code:-0}"
+
+ if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
+ echo "::warning::Event firmware API returned HTTP $http_code. Skipping event firmware update."
+ echo "status=error" >> "$GITHUB_OUTPUT"
+ echo "detail=HTTP $http_code from event firmware API" >> "$GITHUB_OUTPUT"
+ elif ! jq empty "$temp_event_firmware_file" 2>/dev/null; then
+ echo "::warning::Event firmware API returned invalid JSON data. Skipping event firmware update."
+ echo "status=error" >> "$GITHUB_OUTPUT"
+ echo "detail=Invalid JSON response from event firmware API" >> "$GITHUB_OUTPUT"
+ else
+ if [ ! -f "$event_firmware_file_path" ] || ! jq --sort-keys . "$temp_event_firmware_file" | diff -q - <(jq --sort-keys . "$event_firmware_file_path"); then
+ echo "Changes detected in event firmware metadata or local file missing. Updating $event_firmware_file_path."
+ cp "$temp_event_firmware_file" "$event_firmware_file_path"
+ echo "status=updated" >> "$GITHUB_OUTPUT"
+ else
+ echo "No changes detected in event firmware metadata."
+ echo "status=unchanged" >> "$GITHUB_OUTPUT"
+ fi
+ fi
+
- name: Sync with Crowdin
uses: crowdin/github-action@v2
with:
@@ -113,11 +142,22 @@ jobs:
- name: Build PR body
id: pr_body
+ env:
+ # Hoisted into env: (not interpolated directly in the script) so no step output can inject shell — see
+ # zizmor template-injection. Values are fixed strings today, but detail may carry API text in future.
+ FIRMWARE_STATUS: ${{ steps.firmware.outputs.status }}
+ FIRMWARE_DETAIL: ${{ steps.firmware.outputs.detail }}
+ HARDWARE_STATUS: ${{ steps.hardware.outputs.status }}
+ HARDWARE_DETAIL: ${{ steps.hardware.outputs.detail }}
+ EVENT_FIRMWARE_STATUS: ${{ steps.event_firmware.outputs.status }}
+ EVENT_FIRMWARE_DETAIL: ${{ steps.event_firmware.outputs.detail }}
run: |
- firmware_status="${{ steps.firmware.outputs.status }}"
- firmware_detail="${{ steps.firmware.outputs.detail }}"
- hardware_status="${{ steps.hardware.outputs.status }}"
- hardware_detail="${{ steps.hardware.outputs.detail }}"
+ firmware_status="$FIRMWARE_STATUS"
+ firmware_detail="$FIRMWARE_DETAIL"
+ hardware_status="$HARDWARE_STATUS"
+ hardware_detail="$HARDWARE_DETAIL"
+ event_firmware_status="$EVENT_FIRMWARE_STATUS"
+ event_firmware_detail="$EVENT_FIRMWARE_DETAIL"
body="This PR includes automated updates from the scheduled workflow:"
body+=$'\n'
@@ -138,6 +178,14 @@ jobs:
*) body+=$'\n'"- ❓ \`device_hardware.json\` — unknown status." ;;
esac
+ # Event firmware status
+ case "$event_firmware_status" in
+ updated) body+=$'\n'"- ✅ \`event_firmware.json\` updated from the Meshtastic API." ;;
+ unchanged) body+=$'\n'"- ✔️ \`event_firmware.json\` checked — no changes detected." ;;
+ error) body+=$'\n'"- ⚠️ \`event_firmware.json\` skipped — ${event_firmware_detail}." ;;
+ *) body+=$'\n'"- ❓ \`event_firmware.json\` — unknown status." ;;
+ esac
+
# Crowdin (always attempted)
body+=$'\n'"- Source strings were uploaded to Crowdin."
body+=$'\n'"- Latest translations were downloaded from Crowdin (if available)."
@@ -161,6 +209,7 @@ jobs:
Automated updates for:
- Firmware releases list
- Device hardware list
+ - Event firmware metadata
- Crowdin source string uploads
- Crowdin translation downloads
title: 'chore: Scheduled updates (Firmware, Hardware, Translations)'
@@ -171,6 +220,7 @@ jobs:
add-paths: |
androidApp/src/main/assets/firmware_releases.json
androidApp/src/main/assets/device_hardware.json
+ androidApp/src/main/assets/event_firmware.json
fastlane/metadata/android/**
**/strings.xml
docs/**/*.md
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 163409208..ce8bd78bf 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -582,6 +582,8 @@ firebase_link
### FIRMWARE ###
firmware
firmware_edition
+firmware_event_ended_banner
+firmware_event_ended_button
firmware_old
firmware_recovery_banner
firmware_recovery_ble_failed
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt
index 4cdaabc0b..e45e0433a 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt
@@ -18,6 +18,7 @@ package org.meshtastic.app.di
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
+import org.meshtastic.core.model.EventFirmwareResponse
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLinksResponse
import org.meshtastic.core.model.NetworkFirmwareReleases
@@ -42,5 +43,8 @@ class FDroidNetworkModule {
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases =
throw UnsupportedOperationException("getFirmwareReleases is not supported on F-Droid builds.")
+
+ override suspend fun getEventFirmware(): EventFirmwareResponse =
+ throw UnsupportedOperationException("getEventFirmware is not supported on F-Droid builds.")
}
}
diff --git a/androidApp/src/main/assets/event_firmware.json b/androidApp/src/main/assets/event_firmware.json
index 2ce7fee2e..362fab796 100644
--- a/androidApp/src/main/assets/event_firmware.json
+++ b/androidApp/src/main/assets/event_firmware.json
@@ -1,63 +1,183 @@
{
- "version": 1,
- "generatedAt": "2026-06-23T00:00:00Z",
+ "version": 2,
+ "generatedAt": "2026-06-30T00:00:00Z",
"source": "bundled",
"editions": [
{
"edition": "HAMVENTION",
- "displayName": "Hamvention",
+ "displayName": "Dayton Hamvention 2026",
"welcomeMessage": "Welcome to Hamvention! 🍖📻",
+ "tag": "Hamvention",
"eventStart": "2026-05-15",
"eventEnd": "2026-05-17",
"timeZone": "America/New_York",
"location": "Xenia, Ohio, USA",
- "iconUrl": null,
- "accentColor": "#005DAA",
+ "iconUrl": "https://api.meshtastic.org/resource/eventFirmware/hamvention.png",
+ "accentColor": "#BF1E2E",
+ "domain": "hamvention.meshtastic.org",
"links": [
- { "label": "Event Website", "url": "https://hamvention.org" }
- ]
+ {
+ "label": "Event Website",
+ "url": "https://hamvention.org"
+ }
+ ],
+ "theme": {
+ "name": "Radio Adventure",
+ "tagline": "Exploring the many avenues amateur radio has to offer.",
+ "colors": {
+ "primary": "#BF1E2E",
+ "secondary": null,
+ "accent": null
+ },
+ "palette": [
+ "#BF1E2E"
+ ],
+ "fonts": null
+ },
+ "firmware": {
+ "slug": "dayton2026",
+ "version": "2.7.23.07741e6",
+ "id": "v2.7.23.07741e6",
+ "title": "Meshtastic Firmware 2.7.23.07741e6",
+ "zipUrl": "https://github.com/meshtastic/meshtastic.github.io/raw/master/event/dayton2026/firmware-2.7.23.07741e6.zip",
+ "releaseNotes": "## Welcome to Dayton Hamvention 2026!\n\nThis firmware has been customized for Hamvention with factory default configurations.\n\n### ⚠️ Important: Backup Before Flashing\n\nIf your device has existing settings or encryption keys, **backup your keys / configurations** before proceeding. Flashing will reset your device to factory settings for the event.\n\n### Quick Start\n\n1. Ensure a **data-capable USB cable** is connected\n2. Select your device type\n3. Choose \"Full Erase and Install\"\n4. After flashing, download the Meshtastic app and pair via Bluetooth\n5. If you updated from a previous version or installed a UF2 on an NRF52 device, you will need to perform a factory reset on the device to activate the Hamvention mode.\n\n**73 and happy meshing from Dayton!**"
+ }
},
{
"edition": "OPEN_SAUCE",
- "displayName": "Open Sauce",
+ "displayName": "Open Sauce 2026",
"welcomeMessage": "Welcome to Open Sauce! 🔧",
- "eventStart": "2026-07-18",
+ "tag": "Open Sauce",
+ "eventStart": "2026-07-17",
"eventEnd": "2026-07-19",
"timeZone": "America/Los_Angeles",
"location": "San Mateo, California, USA",
"iconUrl": null,
"accentColor": "#E94F1D",
+ "domain": "opensauce.meshtastic.org",
"links": [
- { "label": "Event Website", "url": "https://opensauce.com" }
- ]
+ {
+ "label": "Event Website",
+ "url": "https://opensauce.com"
+ }
+ ],
+ "theme": {
+ "name": "Makers & Mesh",
+ "tagline": "Where makers, science, and mesh collide.",
+ "colors": {
+ "primary": "#E94F1D",
+ "secondary": null,
+ "accent": null
+ },
+ "palette": [
+ "#E94F1D"
+ ],
+ "fonts": null
+ },
+ "firmware": {
+ "slug": "opensauce2026",
+ "version": "2.7.26.004b486",
+ "id": "v2.7.26.004b486",
+ "title": "Meshtastic Firmware 2.7.26.004b486",
+ "zipUrl": "https://github.com/meshtastic/meshtastic.github.io/raw/master/event/opensauce2026/firmware-2.7.26.004b486.zip",
+ "releaseNotes": "## Welcome to Open Sauce 2026! 🔧\n\nThis firmware has been customized for Open Sauce with factory default configurations.\n\n### ⚠️ Important: Backup Before Flashing\n\nIf your device has existing settings or encryption keys, **backup your keys / configurations** before proceeding. Flashing will reset your device to factory settings for the event.\n\n### Quick Start\n\n1. Ensure a **data-capable USB cable** is connected\n2. Select your device type\n3. Choose \"Full Erase and Install\"\n4. After flashing, download the Meshtastic app and pair via Bluetooth\n5. If you updated from a previous version or installed a UF2 on an NRF52 device, you will need to perform a factory reset on the device to activate the Open Sauce mode.\n\n**Happy meshing from San Mateo!**"
+ }
},
{
"edition": "DEFCON",
- "displayName": "DEFCON",
- "welcomeMessage": "Welcome to DEFCON! 💀",
+ "displayName": "DEF CON 34",
+ "welcomeMessage": "Welcome to DEF CON 34! 💀",
+ "tag": "DEFCON",
"eventStart": "2026-08-06",
"eventEnd": "2026-08-09",
"timeZone": "America/Los_Angeles",
- "location": "Las Vegas, Nevada, USA",
+ "location": "Las Vegas Convention Center, Las Vegas, Nevada, USA",
"iconUrl": null,
- "accentColor": "#C0392B",
+ "accentColor": "#0D294A",
+ "domain": "defcon.meshtastic.org",
"links": [
- { "label": "Event Website", "url": "https://defcon.org" }
- ]
+ {
+ "label": "Event Website",
+ "url": "https://defcon.org"
+ },
+ {
+ "label": "DEF CON 34 Theme",
+ "url": "https://defcon.org/html/defcon-34/dc-34-theme.html"
+ },
+ {
+ "label": "Mastodon",
+ "url": "https://defcon.social"
+ }
+ ],
+ "theme": {
+ "name": "Agency",
+ "tagline": "Agency is self-determination. It's about making choices that increase yours, AND helping others to control theirs.",
+ "colors": {
+ "primary": "#0D294A",
+ "secondary": "#017FA4",
+ "accent": "#E0004E"
+ },
+ "palette": [
+ "#0D294A",
+ "#017FA4",
+ "#105F66",
+ "#6CCDB8",
+ "#F1B435",
+ "#E0004E"
+ ],
+ "fonts": {
+ "heading": "Lato",
+ "body": "Atkinson Hyperlegible"
+ }
+ },
+ "firmware": {
+ "slug": "defcon2026",
+ "version": null,
+ "id": null,
+ "title": null,
+ "zipUrl": null,
+ "releaseNotes": null
+ }
},
{
"edition": "BURNING_MAN",
- "displayName": "Burning Man",
+ "displayName": "Burning Man 2026",
"welcomeMessage": "Welcome to Burning Man! 🔥",
+ "tag": "Burning Man",
"eventStart": "2026-08-30",
"eventEnd": "2026-09-07",
"timeZone": "America/Los_Angeles",
"location": "Black Rock City, Nevada, USA",
"iconUrl": null,
- "accentColor": "#E8552D",
+ "accentColor": "#EC8819",
+ "domain": "burningman.meshtastic.org",
"links": [
- { "label": "Event Website", "url": "https://burningman.org" }
- ]
+ {
+ "label": "Event Website",
+ "url": "https://burningman.org"
+ }
+ ],
+ "theme": {
+ "name": "Axis Mundi",
+ "tagline": "The axis of the world — a celestial column connecting earth, sky, and the realms between.",
+ "colors": {
+ "primary": "#EC8819",
+ "secondary": null,
+ "accent": null
+ },
+ "palette": [
+ "#EC8819"
+ ],
+ "fonts": null
+ },
+ "firmware": {
+ "slug": "burningman2026",
+ "version": null,
+ "id": null,
+ "title": null,
+ "zipUrl": null,
+ "releaseNotes": null
+ }
}
]
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/EventFirmwareEditionLocalDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/EventFirmwareEditionLocalDataSource.kt
new file mode 100644
index 000000000..dfc214ead
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/EventFirmwareEditionLocalDataSource.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.datasource
+
+import kotlinx.coroutines.withContext
+import org.koin.core.annotation.Single
+import org.meshtastic.core.database.DatabaseProvider
+import org.meshtastic.core.database.entity.EventFirmwareEditionEntity
+import org.meshtastic.core.di.CoroutineDispatchers
+
+@Single
+class EventFirmwareEditionLocalDataSource(
+ private val dbManager: DatabaseProvider,
+ private val dispatchers: CoroutineDispatchers,
+) {
+ private val dao
+ get() = dbManager.currentDb.value.eventFirmwareEditionDao()
+
+ suspend fun getByEdition(edition: String): EventFirmwareEditionEntity? =
+ withContext(dispatchers.io) { dao.getByEdition(edition) }
+
+ suspend fun upsertAll(editions: List) =
+ withContext(dispatchers.io) { dao.upsertAll(editions) }
+
+ // No-op on empty: `NOT IN ()` is always true in SQLite, so forwarding an empty list would wipe the whole table.
+ // Callers only prune against a non-empty upstream payload; an empty list means "keep everything".
+ suspend fun deleteNotIn(keep: List) {
+ if (keep.isEmpty()) return
+ withContext(dispatchers.io) { dao.deleteNotIn(keep) }
+ }
+
+ suspend fun count(): Int = withContext(dispatchers.io) { dao.count() }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImpl.kt
index c1e1aa31c..084ba6db6 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImpl.kt
@@ -17,57 +17,130 @@
package org.meshtastic.core.data.repository
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.data.datasource.BundledAssetReader
+import org.meshtastic.core.data.datasource.EventFirmwareEditionLocalDataSource
import org.meshtastic.core.data.datasource.decode
+import org.meshtastic.core.database.entity.asEntity
+import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.EventFirmwareEdition
import org.meshtastic.core.model.EventFirmwareResponse
+import org.meshtastic.core.model.util.TimeConstants
+import org.meshtastic.core.network.EventFirmwareRemoteDataSource
import org.meshtastic.core.repository.EventFirmwareRepository
import kotlin.concurrent.Volatile
+import kotlin.time.Duration.Companion.minutes
/**
- * ponytail: bundled-only — the snapshot is a handful of static records, so it's decoded once into memory with no DB or
- * network refresh (unlike [DeviceLinkRepositoryImpl]). Add a `/resource/eventFirmware` refresh when that API ships.
+ * Caches event-firmware display metadata in the DB, seeded from the bundled `event_firmware.json` snapshot and
+ * refreshed from `/resource/eventFirmware`. DB-backed (not just in-memory) so the snapshot survives across process
+ * restarts without waiting on a fresh network round-trip. Mirrors [DeviceHardwareRepositoryImpl]'s seed → single-flight
+ * refresh pattern.
*/
@Single
class EventFirmwareRepositoryImpl(
+ private val remoteDataSource: EventFirmwareRemoteDataSource,
private val assetReader: BundledAssetReader,
private val json: Json,
+ private val localDataSource: EventFirmwareEditionLocalDataSource,
private val dispatchers: CoroutineDispatchers,
) : EventFirmwareRepository {
- private val mutex = Mutex()
+ /** Serializes seeding so concurrent callers don't duplicate writes. */
+ private val writeMutex = Mutex()
- @Volatile private var cache: Map? = null
+ @Volatile private var lastRefreshMillis = 0L
- override suspend fun getEdition(editionName: String): EventFirmwareEdition? = load()[editionName]
+ // Advanced on every refresh *attempt* (success, empty, or failure), unlike lastRefreshMillis which only advances on
+ // a stored non-empty payload. Without this, an offline device (a common, expected state) would re-enter the stale
+ // branch and block up to NETWORK_REFRESH_TIMEOUT_MS on every getEdition() call, since the failed Deferred is no
+ // longer active. The cooldown caps retries while offline without waiting the full CACHE_EXPIRATION_TIME_MS.
+ @Volatile private var lastAttemptMillis = 0L
+
+ /** Guards [inFlightRefresh] so concurrent callers share one network fetch. */
+ private val refreshGuard = Mutex()
+
+ private var inFlightRefresh: Deferred? = null
+
+ // This @Single lives for the entire app lifetime, so the SupervisorJob is never cancelled. The refresh runs
+ // here so a caller that stops waiting can't abort the shared fetch — api.meshtastic.org has been measured
+ // taking 20-60s, and the fetch should still land in the DB for the next lookup.
+ private val refreshScope = CoroutineScope(dispatchers.io + SupervisorJob())
+
+ override suspend fun getEdition(editionName: String): EventFirmwareEdition? = withContext(dispatchers.io) {
+ ensureSeeded()
+ val stale = nowMillis - lastRefreshMillis > CACHE_EXPIRATION_TIME_MS
+ val retryCooldownElapsed = nowMillis - lastAttemptMillis > REFRESH_RETRY_COOLDOWN_MS
+ if (stale && retryCooldownElapsed) {
+ singleFlightRefresh(maxWaitMs = NETWORK_REFRESH_TIMEOUT_MS)
+ }
+ localDataSource.getByEdition(editionName)?.asExternalModel()
+ }
+
+ /** Seeds the table from the bundled snapshot if empty (fresh install, data clear). */
+ private suspend fun ensureSeeded() {
+ if (localDataSource.count() > 0) return
+ writeMutex.withLock {
+ if (localDataSource.count() == 0) {
+ safeCatching {
+ val editions = assetReader.decode(ASSET_NAME, json)?.editions.orEmpty()
+ localDataSource.upsertAll(editions.map { it.asEntity() })
+ }
+ .onFailure { e -> Logger.w(e) { "EventFirmwareRepository: failed to seed from bundled JSON" } }
+ }
+ }
+ }
/**
- * Decodes the bundled snapshot once, keyed by edition name. Empty (not crashing) if the asset is absent/malformed.
+ * Starts (or joins) a single shared refresh running in [refreshScope]. The caller waits at most [maxWaitMs] before
+ * falling back to cached data; the refresh itself always runs to completion, bounded only by the HttpClient's own
+ * timeout/retry policy.
*/
- private suspend fun load(): Map {
- cache?.let {
- return it
- }
- return mutex.withLock {
- cache
- ?: withContext(dispatchers.io) {
- safeCatching { assetReader.decode(ASSET_NAME, json)?.editions.orEmpty() }
- .onFailure { e -> Logger.w(e) { "EventFirmwareRepository: failed to read bundled JSON" } }
- .getOrDefault(emptyList())
- .associateBy { it.edition }
- }
- .also { cache = it }
+ private suspend fun singleFlightRefresh(maxWaitMs: Long) {
+ val refresh =
+ refreshGuard.withLock {
+ inFlightRefresh?.takeIf { it.isActive }
+ ?: refreshScope
+ .async {
+ lastAttemptMillis = nowMillis
+ safeCatching {
+ val editions = remoteDataSource.getEventFirmware().editions
+ if (editions.isNotEmpty()) {
+ localDataSource.upsertAll(editions.map { it.asEntity() })
+ localDataSource.deleteNotIn(editions.map { it.edition })
+ lastRefreshMillis = nowMillis
+ }
+ }
+ .onFailure { e -> Logger.w(e) { "EventFirmwareRepository: network refresh failed" } }
+ Unit
+ }
+ .also { inFlightRefresh = it }
+ }
+ if (withTimeoutOrNull(maxWaitMs) { refresh.join() } == null) {
+ Logger.w { "EventFirmwareRepository: refresh still in flight after ${maxWaitMs}ms; using cached data" }
}
}
private companion object {
private const val ASSET_NAME = "event_firmware.json"
+ private val CACHE_EXPIRATION_TIME_MS = TimeConstants.ONE_DAY.inWholeMilliseconds
+
+ /** Minimum gap between refresh *attempts*, so a failing/empty fetch (e.g. offline) doesn't retry every call. */
+ private val REFRESH_RETRY_COOLDOWN_MS = 5.minutes.inWholeMilliseconds
+
+ /** Maximum time a blocking lookup waits for an in-flight refresh before returning cached/bundled data. */
+ private const val NETWORK_REFRESH_TIMEOUT_MS = 5_000L
}
}
diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt
index e8bdcced7..65957136e 100644
--- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt
+++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt
@@ -24,6 +24,7 @@ import okio.Source
import org.meshtastic.core.data.datasource.BundledAssetReader
import org.meshtastic.core.data.datasource.DeviceLinkLocalDataSource
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.EventFirmwareResponse
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLink
import org.meshtastic.core.model.NetworkDeviceLinksResponse
@@ -46,6 +47,8 @@ class DeviceLinkRepositoryImplTest {
override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = response
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused")
+
+ override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused")
}
/** Serves only `device_links.json`, serializing the current [links] so the repo seeds via the real decode path. */
diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt
index 51a7e2746..5fb8cc0f6 100644
--- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt
+++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt
@@ -22,73 +22,203 @@ import kotlinx.serialization.json.Json
import okio.Buffer
import okio.Source
import org.meshtastic.core.data.datasource.BundledAssetReader
+import org.meshtastic.core.data.datasource.EventFirmwareEditionLocalDataSource
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.EventFirmwareBuild
import org.meshtastic.core.model.EventFirmwareEdition
import org.meshtastic.core.model.EventFirmwareResponse
+import org.meshtastic.core.model.EventFirmwareTheme
+import org.meshtastic.core.model.EventFirmwareThemeColors
+import org.meshtastic.core.model.NetworkDeviceHardware
+import org.meshtastic.core.model.NetworkDeviceLinksResponse
+import org.meshtastic.core.model.NetworkFirmwareReleases
+import org.meshtastic.core.network.EventFirmwareRemoteDataSource
+import org.meshtastic.core.network.service.ApiService
+import org.meshtastic.core.testing.FakeDatabaseProvider
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class EventFirmwareRepositoryImplTest {
+ /** Only [getEventFirmware] is exercised; the other endpoints are never called by this repository. */
+ private class FakeApiService(var response: EventFirmwareResponse) : ApiService {
+ var eventFirmwareCalls = 0
+ private set
+
+ override suspend fun getDeviceHardware(): List = error("unused")
+
+ override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = error("unused")
+
+ override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused")
+
+ override suspend fun getEventFirmware(): EventFirmwareResponse {
+ eventFirmwareCalls++
+ return response
+ }
+ }
+
/** Serves only `event_firmware.json`, serializing [editions] so the repo decodes via the real path. */
private class FakeBundledAssetReader(
var editions: List,
private val json: Json,
var present: Boolean = true,
) : BundledAssetReader {
- var opens = 0
- private set
-
override fun open(name: String): Source? {
if (name != "event_firmware.json" || !present) return null
- opens++
val bytes = json.encodeToString(EventFirmwareResponse(editions = editions)).encodeToByteArray()
return Buffer().write(bytes)
}
}
private val json = Json { ignoreUnknownKeys = true }
+
+ // Real dispatchers + runBlocking (per test), NOT runTest/UnconfinedTestDispatcher — see the comment in
+ // DeviceLinkRepositoryImplTest for why runTest's virtual clock flakes the withTimeoutOrNull-bounded refresh.
private val unconfined = Dispatchers.Unconfined
private val dispatchers = CoroutineDispatchers(main = unconfined, io = unconfined, default = unconfined)
+ private lateinit var dbProvider: FakeDatabaseProvider
+ private lateinit var local: EventFirmwareEditionLocalDataSource
+ private lateinit var api: FakeApiService
+ private lateinit var seed: FakeBundledAssetReader
+ private lateinit var repository: EventFirmwareRepositoryImpl
+
private fun edition(name: String) =
EventFirmwareEdition(edition = name, displayName = name.lowercase(), welcomeMessage = "hi $name")
+ @BeforeTest
+ fun setup() {
+ dbProvider = FakeDatabaseProvider()
+ local = EventFirmwareEditionLocalDataSource(dbProvider, dispatchers)
+ api = FakeApiService(EventFirmwareResponse())
+ seed = FakeBundledAssetReader(emptyList(), json)
+ repository =
+ EventFirmwareRepositoryImpl(
+ remoteDataSource = EventFirmwareRemoteDataSource(api, dispatchers),
+ assetReader = seed,
+ json = json,
+ localDataSource = local,
+ dispatchers = dispatchers,
+ )
+ }
+
+ @AfterTest fun tearDown() = dbProvider.close()
+
@Test
fun getEditionReturnsMatchingRecordByEnumName() = runBlocking {
- val reader = FakeBundledAssetReader(listOf(edition("HAMVENTION"), edition("DEFCON")), json)
- val repo = EventFirmwareRepositoryImpl(reader, json, dispatchers)
+ seed.editions = listOf(edition("HAMVENTION"), edition("DEFCON"))
- assertEquals("hamvention", repo.getEdition("HAMVENTION")?.displayName)
- assertEquals("hi DEFCON", repo.getEdition("DEFCON")?.welcomeMessage)
+ assertEquals("hamvention", repository.getEdition("HAMVENTION")?.displayName)
+ assertEquals("hi DEFCON", repository.getEdition("DEFCON")?.welcomeMessage)
}
@Test
fun getEditionReturnsNullForUnknownEdition() = runBlocking {
- val repo =
- EventFirmwareRepositoryImpl(FakeBundledAssetReader(listOf(edition("HAMVENTION")), json), json, dispatchers)
+ seed.editions = listOf(edition("HAMVENTION"))
- assertNull(repo.getEdition("VANILLA"))
+ assertNull(repository.getEdition("VANILLA"))
}
@Test
fun absentAssetYieldsNullWithoutCrashing() = runBlocking {
- val repo =
- EventFirmwareRepositoryImpl(FakeBundledAssetReader(emptyList(), json, present = false), json, dispatchers)
+ seed.present = false
- assertNull(repo.getEdition("HAMVENTION"))
+ assertNull(repository.getEdition("HAMVENTION"))
}
@Test
- fun snapshotIsDecodedOnlyOnceAndCached() = runBlocking {
- val reader = FakeBundledAssetReader(listOf(edition("HAMVENTION")), json)
- val repo = EventFirmwareRepositoryImpl(reader, json, dispatchers)
+ fun seedsFromBundledJsonOnlyWhenTableEmpty() = runBlocking {
+ seed.editions = listOf(edition("HAMVENTION"))
+ repository.getEdition("HAMVENTION")
+ assertEquals(1, local.count())
- repo.getEdition("HAMVENTION")
- repo.getEdition("DEFCON")
- repo.getEdition("HAMVENTION")
+ // A larger snapshot must NOT re-seed once the table is populated.
+ seed.editions = seed.editions + edition("DEFCON")
+ repository.getEdition("DEFCON")
+ assertEquals(1, local.count())
+ }
- assertEquals(1, reader.opens)
+ @Test
+ fun networkRefreshReplacesBundledSnapshot() = runBlocking {
+ seed.editions = listOf(edition("HAMVENTION"))
+ api.response = EventFirmwareResponse(editions = listOf(edition("DEFCON")))
+
+ // First call is always stale (never refreshed yet), so it triggers + awaits the network fetch.
+ assertNull(repository.getEdition("HAMVENTION"))
+ assertEquals("hi DEFCON", repository.getEdition("DEFCON")?.welcomeMessage)
+ }
+
+ @Test
+ fun emptyNetworkResponseLeavesBundledSnapshotInPlace() = runBlocking {
+ seed.editions = listOf(edition("HAMVENTION"))
+ api.response = EventFirmwareResponse(editions = emptyList())
+
+ assertEquals("hamvention", repository.getEdition("HAMVENTION")?.displayName)
+ }
+
+ @Test
+ fun failedRefreshDoesNotRetryOnEveryCall() = runBlocking {
+ // Empty response never advances the success timestamp; without the retry cooldown the second call would
+ // re-enter the stale branch and fetch again. The cooldown (minutes) means a second immediate call skips it.
+ seed.editions = listOf(edition("HAMVENTION"))
+ api.response = EventFirmwareResponse(editions = emptyList())
+
+ repository.getEdition("HAMVENTION")
+ repository.getEdition("HAMVENTION")
+
+ assertEquals(1, api.eventFirmwareCalls)
+ }
+
+ @Test
+ fun v2FieldsRoundTripThroughCache() = runBlocking {
+ seed.editions =
+ listOf(
+ EventFirmwareEdition(
+ edition = "HAMVENTION",
+ displayName = "Dayton Hamvention 2026",
+ welcomeMessage = "hi",
+ tag = "Hamvention",
+ domain = "hamvention.meshtastic.org",
+ theme =
+ EventFirmwareTheme(
+ name = "Radio Adventure",
+ palette = listOf("#BF1E2E"),
+ colors = EventFirmwareThemeColors(primary = "#BF1E2E"),
+ ),
+ firmware = EventFirmwareBuild(version = "2.7.23.07741e6", zipUrl = "https://example/f.zip"),
+ ),
+ )
+
+ val got = repository.getEdition("HAMVENTION")!!
+
+ assertEquals("Hamvention", got.tag)
+ assertEquals("hamvention.meshtastic.org", got.domain)
+ assertEquals("Radio Adventure", got.theme?.name)
+ assertEquals("#BF1E2E", got.theme?.colors?.primary)
+ assertEquals(listOf("#BF1E2E"), got.theme?.palette)
+ assertEquals("2.7.23.07741e6", got.firmware?.version)
+ assertEquals("https://example/f.zip", got.firmware?.zipUrl)
+ }
+
+ @Test
+ fun persistsAcrossRepositoryInstancesBackedByTheSameDb() = runBlocking {
+ seed.editions = listOf(edition("HAMVENTION"))
+ repository.getEdition("HAMVENTION")
+
+ // A fresh repository instance (e.g. after a process restart) reads the same DB without re-seeding.
+ seed.editions = emptyList()
+ val restarted =
+ EventFirmwareRepositoryImpl(
+ remoteDataSource = EventFirmwareRemoteDataSource(api, dispatchers),
+ assetReader = seed,
+ json = json,
+ localDataSource = local,
+ dispatchers = dispatchers,
+ )
+
+ assertEquals("hamvention", restarted.getEdition("HAMVENTION")?.displayName)
}
}
diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt
index 92baf9651..5b02c301b 100644
--- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt
+++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt
@@ -27,6 +27,7 @@ import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.EventFirmwareResponse
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLinksResponse
import org.meshtastic.core.model.NetworkFirmwareRelease
@@ -50,6 +51,8 @@ class FirmwareReleaseRepositoryImplTest {
override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = error("unused")
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = response
+
+ override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused")
}
/** Serves `firmware_releases.json` from [bundled] via the real decode path, or nothing when null. */
diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/47.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/47.json
new file mode 100644
index 000000000..b3dd35df9
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/47.json
@@ -0,0 +1,1693 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 47,
+ "identityHash": "902c451b0845b51265520d6e7a7c6b25",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pioEnv",
+ "columnName": "pioEnv",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `power_channel_labels` TEXT NOT NULL DEFAULT '[]', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, `has_xeddsa_signed` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "airQualityTelemetry",
+ "columnName": "air_quality_metrics",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "powerChannelLabels",
+ "columnName": "power_channel_labels",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "nodeStatus",
+ "columnName": "node_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastTransport",
+ "columnName": "last_transport",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "signsPackets",
+ "columnName": "has_xeddsa_signed",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ },
+ {
+ "name": "index_nodes_public_key",
+ "unique": false,
+ "columnNames": [
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '', `translated_text` TEXT, `show_translated` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "filtered",
+ "columnName": "filtered",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "translatedText",
+ "columnName": "translated_text",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "showTranslated",
+ "columnName": "show_translated",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ },
+ {
+ "name": "index_packet_received_time",
+ "unique": false,
+ "columnNames": [
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)"
+ },
+ {
+ "name": "index_packet_filtered",
+ "unique": false,
+ "columnNames": [
+ "filtered"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)"
+ },
+ {
+ "name": "index_packet_read",
+ "unique": false,
+ "columnNames": [
+ "read"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet_fts",
+ "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)",
+ "fields": [
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": []
+ },
+ "ftsVersion": "FTS5",
+ "ftsOptions": {
+ "tokenizer": "unicode61",
+ "tokenizerArgs": [],
+ "contentTable": "packet",
+ "languageIdColumnName": "",
+ "matchInfo": "FTS4",
+ "notIndexedColumns": [],
+ "prefixSizes": [],
+ "preferredOrder": "ASC",
+ "contentRowId": "",
+ "columnSize": true,
+ "detail": "FULL"
+ },
+ "contentSyncTriggers": [
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END"
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filteringDisabled",
+ "columnName": "filtering_disabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "platformio_target"
+ ]
+ }
+ },
+ {
+ "tableName": "device_link",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, `targets` TEXT, PRIMARY KEY(`short_code`))",
+ "fields": [
+ {
+ "fieldPath": "shortCode",
+ "columnName": "short_code",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "linkDescription",
+ "columnName": "link_description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isVendor",
+ "columnName": "is_vendor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "regions",
+ "columnName": "regions",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "targets",
+ "columnName": "targets",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "short_code"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovery_session",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetsScanned",
+ "columnName": "presets_scanned",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "homePreset",
+ "columnName": "home_preset",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalUniqueNodes",
+ "columnName": "total_unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalMessages",
+ "columnName": "total_messages",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "totalSensorPackets",
+ "columnName": "total_sensor_packets",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "furthestNodeDistance",
+ "columnName": "furthest_node_distance",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "completionStatus",
+ "columnName": "completion_status",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'complete'"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "userLatitude",
+ "columnName": "user_latitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "userLongitude",
+ "columnName": "user_longitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalDwellSeconds",
+ "columnName": "total_dwell_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "discovery_preset_result",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `infrastructure_node_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionId",
+ "columnName": "session_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetName",
+ "columnName": "preset_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dwellDurationSeconds",
+ "columnName": "dwell_duration_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uniqueNodes",
+ "columnName": "unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "directNeighborCount",
+ "columnName": "direct_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "meshNeighborCount",
+ "columnName": "mesh_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "infrastructureNodeCount",
+ "columnName": "infrastructure_node_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "avgAirtimeRate",
+ "columnName": "avg_airtime_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetSuccessRate",
+ "columnName": "packet_success_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetFailureRate",
+ "columnName": "packet_failure_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "numPacketsTx",
+ "columnName": "num_packets_tx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRx",
+ "columnName": "num_packets_rx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRxBad",
+ "columnName": "num_packets_rx_bad",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numRxDupe",
+ "columnName": "num_rx_dupe",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelay",
+ "columnName": "num_tx_relay",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelayCanceled",
+ "columnName": "num_tx_relay_canceled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numOnlineNodes",
+ "columnName": "num_online_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTotalNodes",
+ "columnName": "num_total_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uptimeSeconds",
+ "columnName": "uptime_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovery_preset_result_session_id",
+ "unique": false,
+ "columnNames": [
+ "session_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_session",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "session_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovered_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `is_infrastructure` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetResultId",
+ "columnName": "preset_result_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "neighborType",
+ "columnName": "neighbor_type",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'direct'"
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "distanceFromUser",
+ "columnName": "distance_from_user",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "hopCount",
+ "columnName": "hop_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isInfrastructure",
+ "columnName": "is_infrastructure",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovered_node_preset_result_id",
+ "unique": false,
+ "columnNames": [
+ "preset_result_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)"
+ },
+ {
+ "name": "index_discovered_node_node_num",
+ "unique": false,
+ "columnNames": [
+ "node_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_preset_result",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "preset_result_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "event_firmware_edition",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`edition` TEXT NOT NULL, `display_name` TEXT NOT NULL, `welcome_message` TEXT NOT NULL, `event_start` TEXT, `event_end` TEXT, `time_zone` TEXT, `location` TEXT, `icon_url` TEXT, `accent_color` TEXT, `tag` TEXT, `domain` TEXT, `theme_json` TEXT, `firmware_json` TEXT, `links_json` TEXT NOT NULL, PRIMARY KEY(`edition`))",
+ "fields": [
+ {
+ "fieldPath": "edition",
+ "columnName": "edition",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "welcomeMessage",
+ "columnName": "welcome_message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "eventStart",
+ "columnName": "event_start",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "eventEnd",
+ "columnName": "event_end",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "timeZone",
+ "columnName": "time_zone",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "location",
+ "columnName": "location",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "iconUrl",
+ "columnName": "icon_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "accentColor",
+ "columnName": "accent_color",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "tag",
+ "columnName": "tag",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "domain",
+ "columnName": "domain",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "themeJson",
+ "columnName": "theme_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareJson",
+ "columnName": "firmware_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "linksJson",
+ "columnName": "links_json",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "edition"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '902c451b0845b51265520d6e7a7c6b25')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index 2ac09e71c..0ae839a38 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -28,6 +28,7 @@ import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.DeviceLinkDao
import org.meshtastic.core.database.dao.DiscoveryDao
+import org.meshtastic.core.database.dao.EventFirmwareEditionDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.dao.NodeInfoDao
@@ -40,6 +41,7 @@ import org.meshtastic.core.database.entity.DeviceLinkEntity
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
+import org.meshtastic.core.database.entity.EventFirmwareEditionEntity
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
@@ -70,6 +72,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
DiscoverySessionEntity::class,
DiscoveryPresetResultEntity::class,
DiscoveredNodeEntity::class,
+ EventFirmwareEditionEntity::class,
],
autoMigrations =
[
@@ -116,8 +119,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 43, to = 44),
AutoMigration(from = 44, to = 45),
AutoMigration(from = 45, to = 46),
+ AutoMigration(from = 46, to = 47),
],
- version = 46,
+ version = 47,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -142,6 +146,8 @@ abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun discoveryDao(): DiscoveryDao
+ abstract fun eventFirmwareEditionDao(): EventFirmwareEditionDao
+
companion object {
/**
* Configures a [RoomDatabase.Builder] with standard settings for this project.
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/EventFirmwareEditionDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/EventFirmwareEditionDao.kt
new file mode 100644
index 000000000..901df227d
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/EventFirmwareEditionDao.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.database.dao
+
+import androidx.room3.Dao
+import androidx.room3.Query
+import androidx.room3.Upsert
+import org.meshtastic.core.database.entity.EventFirmwareEditionEntity
+
+@Dao
+interface EventFirmwareEditionDao {
+ @Upsert suspend fun upsertAll(editions: List)
+
+ @Query("SELECT * FROM event_firmware_edition WHERE edition = :edition")
+ suspend fun getByEdition(edition: String): EventFirmwareEditionEntity?
+
+ /**
+ * Deletes rows whose edition is not in [keep]. WARNING: `NOT IN ()` is always true in SQLite, so an **empty**
+ * [keep] deletes every row — call sites must guard against passing an empty list (see
+ * `EventFirmwareEditionLocalDataSource.deleteNotIn`, which no-ops on empty).
+ */
+ @Query("DELETE FROM event_firmware_edition WHERE edition NOT IN (:keep)")
+ suspend fun deleteNotIn(keep: List)
+
+ @Query("SELECT COUNT(*) FROM event_firmware_edition")
+ suspend fun count(): Int
+}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/EventFirmwareEditionEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/EventFirmwareEditionEntity.kt
new file mode 100644
index 000000000..1f55e993f
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/EventFirmwareEditionEntity.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.database.entity
+
+import androidx.room3.ColumnInfo
+import androidx.room3.Entity
+import androidx.room3.PrimaryKey
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import org.meshtastic.core.model.EventFirmwareBuild
+import org.meshtastic.core.model.EventFirmwareEdition
+import org.meshtastic.core.model.EventFirmwareLink
+import org.meshtastic.core.model.EventFirmwareTheme
+
+/** Lenient so decoding a cached column survives a model that gained fields since it was written (forward-compat). */
+private val entityJson = Json { ignoreUnknownKeys = true }
+
+/**
+ * An event-firmware display record, cached from the Meshtastic API (`/resource/eventFirmware`) during refresh. The
+ * nested [links]/[theme]/[firmware] objects are stored pre-serialized rather than via Room type converters — this is
+ * the only entity that needs those column types, so shared [org.meshtastic.core.database.Converters] entries would be
+ * unused elsewhere.
+ */
+@Serializable
+@Entity(tableName = "event_firmware_edition")
+data class EventFirmwareEditionEntity(
+ @PrimaryKey val edition: String,
+ @ColumnInfo(name = "display_name") val displayName: String = "",
+ @ColumnInfo(name = "welcome_message") val welcomeMessage: String = "",
+ @ColumnInfo(name = "event_start") val eventStart: String? = null,
+ @ColumnInfo(name = "event_end") val eventEnd: String? = null,
+ @ColumnInfo(name = "time_zone") val timeZone: String? = null,
+ val location: String? = null,
+ @ColumnInfo(name = "icon_url") val iconUrl: String? = null,
+ @ColumnInfo(name = "accent_color") val accentColor: String? = null,
+ val tag: String? = null,
+ val domain: String? = null,
+ @ColumnInfo(name = "theme_json") val themeJson: String? = null,
+ @ColumnInfo(name = "firmware_json") val firmwareJson: String? = null,
+ @ColumnInfo(name = "links_json") val linksJson: String = "[]",
+)
+
+fun EventFirmwareEdition.asEntity() = EventFirmwareEditionEntity(
+ edition = edition,
+ displayName = displayName,
+ welcomeMessage = welcomeMessage,
+ eventStart = eventStart,
+ eventEnd = eventEnd,
+ timeZone = timeZone,
+ location = location,
+ iconUrl = iconUrl,
+ accentColor = accentColor,
+ tag = tag,
+ domain = domain,
+ themeJson = theme?.let { entityJson.encodeToString(it) },
+ firmwareJson = firmware?.let { entityJson.encodeToString(it) },
+ linksJson = entityJson.encodeToString(links),
+)
+
+fun EventFirmwareEditionEntity.asExternalModel() = EventFirmwareEdition(
+ edition = edition,
+ displayName = displayName,
+ welcomeMessage = welcomeMessage,
+ eventStart = eventStart,
+ eventEnd = eventEnd,
+ timeZone = timeZone,
+ location = location,
+ iconUrl = iconUrl,
+ accentColor = accentColor,
+ tag = tag,
+ domain = domain,
+ theme = themeJson?.let { runCatching { entityJson.decodeFromString(it) }.getOrNull() },
+ firmware =
+ firmwareJson?.let { runCatching { entityJson.decodeFromString(it) }.getOrNull() },
+ links =
+ runCatching { entityJson.decodeFromString>(linksJson) }.getOrDefault(emptyList()),
+)
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt
index 195fc831b..613c48fa1 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt
@@ -21,9 +21,9 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
/**
- * Response envelope for event-firmware display metadata. Matches the bundled `event_firmware.json` and the planned `GET
- * /resource/eventFirmware` API, sharing the `{version, generatedAt, source, []}` shape used by
- * [NetworkDeviceLinksResponse]. See `schemas/event_firmware.schema.json` for the authoring contract.
+ * Response envelope for event-firmware display metadata. Matches the bundled `event_firmware.json` and the live `GET
+ * https://api.meshtastic.org/resource/eventFirmware` API (the source of truth), sharing the `{version, generatedAt,
+ * source, []}` shape used by [NetworkDeviceLinksResponse].
*/
@Serializable
@OptIn(ExperimentalSerializationApi::class)
@@ -47,8 +47,12 @@ data class EventFirmwareResponse(
* @param eventEnd ISO-8601 date the event ends, in [timeZone].
* @param timeZone IANA time-zone id so [eventStart]/[eventEnd] resolve in the event's local time.
* @param location free-text venue/city for display.
- * @param iconUrl URL of the event branding icon; `null` until icons are hosted (bundled drawables fill in meanwhile).
- * @param accentColor `#RRGGBB` brand color for ambient theming (not yet consumed).
+ * @param iconUrl URL of the event branding icon (hosted on api.meshtastic.org); bundled drawables are the fallback.
+ * @param accentColor `#RRGGBB` brand color for ambient theming.
+ * @param tag short label for the event, e.g. `Hamvention`.
+ * @param domain per-event microsite host, e.g. `hamvention.meshtastic.org`.
+ * @param theme richer branding (tagline, palette, …) layered over [accentColor]; optional, not all editions carry it.
+ * @param firmware the event's own firmware build (version + download); lets a client offer/flash it directly.
* @param links labeled links for the event (website, schedule, …).
*/
@Serializable
@@ -64,6 +68,10 @@ data class EventFirmwareEdition(
val location: String? = null,
val iconUrl: String? = null,
val accentColor: String? = null,
+ val tag: String? = null,
+ val domain: String? = null,
+ val theme: EventFirmwareTheme? = null,
+ val firmware: EventFirmwareBuild? = null,
val links: List = emptyList(),
)
@@ -72,3 +80,40 @@ data class EventFirmwareEdition(
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
data class EventFirmwareLink(val label: String = "", val url: String = "")
+
+/**
+ * Richer event branding beyond a single accent color. `fonts` is intentionally omitted — it is `null` in the current
+ * feed and its shape is unspecified, so it is left to [JsonIgnoreUnknownKeys] rather than modeled prematurely.
+ */
+@Serializable
+@OptIn(ExperimentalSerializationApi::class)
+@JsonIgnoreUnknownKeys
+data class EventFirmwareTheme(
+ val name: String? = null,
+ val tagline: String? = null,
+ val colors: EventFirmwareThemeColors? = null,
+ val palette: List = emptyList(),
+)
+
+/** Named brand colors (`#RRGGBB`) for an event theme; any may be absent. */
+@Serializable
+@OptIn(ExperimentalSerializationApi::class)
+@JsonIgnoreUnknownKeys
+data class EventFirmwareThemeColors(
+ val primary: String? = null,
+ val secondary: String? = null,
+ val accent: String? = null,
+)
+
+/** The event's own firmware release, so a client can surface its version or offer the download ([zipUrl]). */
+@Serializable
+@OptIn(ExperimentalSerializationApi::class)
+@JsonIgnoreUnknownKeys
+data class EventFirmwareBuild(
+ val slug: String? = null,
+ val version: String? = null,
+ val id: String? = null,
+ val title: String? = null,
+ val zipUrl: String? = null,
+ val releaseNotes: String? = null,
+)
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/EventFirmwareRemoteDataSource.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/EventFirmwareRemoteDataSource.kt
new file mode 100644
index 000000000..843e030d7
--- /dev/null
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/EventFirmwareRemoteDataSource.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.network
+
+import kotlinx.coroutines.withContext
+import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.EventFirmwareResponse
+import org.meshtastic.core.network.service.ApiService
+
+@Single
+class EventFirmwareRemoteDataSource(private val apiService: ApiService, private val dispatchers: CoroutineDispatchers) {
+ suspend fun getEventFirmware(): EventFirmwareResponse =
+ withContext(dispatchers.io) { apiService.getEventFirmware() }
+}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
index 94c7697a9..b0fd88d3f 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
@@ -20,6 +20,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import org.koin.core.annotation.Single
+import org.meshtastic.core.model.EventFirmwareResponse
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLinksResponse
import org.meshtastic.core.model.NetworkFirmwareReleases
@@ -34,6 +35,9 @@ interface ApiService {
/** Fetches the list of available firmware releases from the Meshtastic API. */
suspend fun getFirmwareReleases(): NetworkFirmwareReleases
+
+ /** Fetches event-firmware display metadata (editions, welcome messages, links) from the Meshtastic API. */
+ suspend fun getEventFirmware(): EventFirmwareResponse
}
/**
@@ -51,4 +55,6 @@ class ApiServiceImpl(private val client: HttpClient) : ApiService {
override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = client.get("resource/deviceLinks").body()
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body()
+
+ override suspend fun getEventFirmware(): EventFirmwareResponse = client.get("resource/eventFirmware").body()
}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 9c736c4bd..95ef6cc5b 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -606,6 +606,8 @@
Firmware
Firmware Edition
+ %1$s has ended. Return to standard Meshtastic firmware to restore normal features.
+ Update firmware
The radio firmware is too old to talk to this application. For more information on this see our Firmware Installation guide.
Finish updating %1$s
Couldn\'t finish the update over Bluetooth. This device\'s stock bootloader can\'t reliably complete an interrupted update over the air. Connect it to a computer with USB and re-flash it using the vendor\'s serial DFU tool (for example adafruit-nrfutil) to recover the device.
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt
index 4189ad2f1..77dfd0903 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.core.ui.component
-import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -42,20 +41,18 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
-import org.jetbrains.compose.resources.painterResource
import org.meshtastic.core.model.EventFirmwareEdition
import org.meshtastic.core.ui.icon.CalendarMonth
import org.meshtastic.core.ui.icon.ChevronRight
import org.meshtastic.core.ui.icon.LinkIcon
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Place
+import org.meshtastic.core.ui.util.EventBrandingIcon
import org.meshtastic.core.ui.util.accentColorOrNull
-import org.meshtastic.core.ui.util.eventIconFor
/**
* Bottom sheet shown when the user taps the event branding in [MainAppBar]. Surfaces the event metadata the bundled
@@ -108,14 +105,11 @@ private fun EventHeader(edition: EventFirmwareEdition, accent: Color?) {
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- eventIconFor(edition.edition)?.let { icon ->
- Image(
- painter = painterResource(icon),
- contentDescription = null,
- contentScale = ContentScale.Fit,
- modifier = Modifier.size(48.dp).clip(CircleShape),
- )
- }
+ EventBrandingIcon(
+ edition = edition,
+ modifier = Modifier.size(48.dp).clip(CircleShape),
+ contentDescription = null,
+ )
Text(
text = edition.displayName,
style = MaterialTheme.typography.headlineSmall,
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt
index 8d75d0970..0b1fb79cc 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt
@@ -19,7 +19,6 @@ package org.meshtastic.core.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
-import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -40,11 +39,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.compositeOver
-import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.model.Node
@@ -53,9 +50,9 @@ import org.meshtastic.core.resources.ic_meshtastic
import org.meshtastic.core.resources.navigate_back
import org.meshtastic.core.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.util.EventBrandingIcon
import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.accentColorOrNull
-import org.meshtastic.core.ui.util.eventIconFor
/** Alpha for the ambient event accent wash over the app bar — subtle enough to keep title text legible. */
private const val EVENT_ACCENT_ALPHA = 0.12f
@@ -134,24 +131,11 @@ private fun EventAwareBranding() {
Icon(imageVector = vectorResource(Res.drawable.ic_meshtastic), contentDescription = null)
return
}
- // Every event edition is tappable for its info sheet; editions without a bundled icon reuse the Meshtastic logo.
+ // Every event edition is tappable for its info sheet. The icon prefers the hosted iconUrl, then a bundled
+ // drawable, then the Meshtastic logo — see EventBrandingIcon.
var showSheet by remember { mutableStateOf(false) }
val brandingModifier = Modifier.size(32.dp).clip(CircleShape).clickable(role = Role.Button) { showSheet = true }
- val iconRes = eventIconFor(eventEdition.edition)
- if (iconRes != null) {
- Image(
- painter = painterResource(iconRes),
- contentDescription = eventEdition.displayName,
- contentScale = ContentScale.Fit,
- modifier = brandingModifier,
- )
- } else {
- Icon(
- imageVector = vectorResource(Res.drawable.ic_meshtastic),
- contentDescription = eventEdition.displayName,
- modifier = brandingModifier,
- )
- }
+ EventBrandingIcon(edition = eventEdition, modifier = brandingModifier)
if (showSheet) {
EventInfoSheet(edition = eventEdition, onDismiss = { showSheet = false })
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt
index d3548e7e3..246fc1619 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt
@@ -16,12 +16,25 @@
*/
package org.meshtastic.core.ui.util
+import androidx.compose.foundation.Image
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.layout.ContentScale
+import coil3.compose.AsyncImage
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.todayIn
import org.jetbrains.compose.resources.DrawableResource
+import org.jetbrains.compose.resources.painterResource
+import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.model.EventFirmwareEdition
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.ic_meshtastic
import org.meshtastic.core.resources.img_event_hamvention
+import kotlin.time.Clock
/**
* Provides the active [EventFirmwareEdition] (if any) to the composition tree. When a connected device reports an event
@@ -33,14 +46,61 @@ import org.meshtastic.core.resources.img_event_hamvention
val LocalEventBranding = compositionLocalOf { null }
/**
- * Bundled branding drawable for an edition, or `null`. The metadata's `iconUrl` is null until icons are hosted, so the
- * one drawable we ship stays code-mapped here; remove this once icons are loaded from [EventFirmwareEdition.iconUrl].
+ * Bundled branding drawable for an edition, or `null`. Used as the offline fallback by [EventBrandingIcon] when a
+ * hosted [EventFirmwareEdition.iconUrl] is absent or fails to load.
*/
fun eventIconFor(editionName: String): DrawableResource? = when (editionName) {
"HAMVENTION" -> Res.drawable.img_event_hamvention
else -> null
}
+/**
+ * Event branding icon: loads the hosted [EventFirmwareEdition.iconUrl] when present, falling back to the bundled
+ * per-edition drawable ([eventIconFor]), and finally the Meshtastic logo. The fallback painter also backs Coil's
+ * loading/error states so there is never an empty slot.
+ */
+@Composable
+fun EventBrandingIcon(
+ edition: EventFirmwareEdition,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = edition.displayName,
+) {
+ val bundled = eventIconFor(edition.edition)
+ val fallback =
+ bundled?.let { painterResource(it) } ?: rememberVectorPainter(vectorResource(Res.drawable.ic_meshtastic))
+ val url = edition.iconUrl
+ if (url.isNullOrBlank()) {
+ Image(
+ painter = fallback,
+ contentDescription = contentDescription,
+ contentScale = ContentScale.Fit,
+ modifier = modifier,
+ )
+ } else {
+ AsyncImage(
+ model = url,
+ contentDescription = contentDescription,
+ modifier = modifier,
+ contentScale = ContentScale.Fit,
+ placeholder = fallback,
+ error = fallback,
+ fallback = fallback,
+ )
+ }
+}
+
+/**
+ * Whether the event's last day is in the past, evaluated in the event's own [timeZone][EventFirmwareEdition.timeZone]
+ * (falling back to the device time zone). Used to nudge users off event firmware once the event is over. Returns
+ * `false` when [eventEnd][EventFirmwareEdition.eventEnd] is absent or unparseable — an unknown end date is never
+ * treated as ended.
+ */
+fun EventFirmwareEdition.hasEnded(): Boolean {
+ val end = eventEnd?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return false
+ val zone = timeZone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: TimeZone.currentSystemDefault()
+ return Clock.System.todayIn(zone) > end
+}
+
/** Parses the edition's `#RRGGBB` [EventFirmwareEdition.accentColor] into a [Color], or `null` if absent/malformed. */
fun EventFirmwareEdition.accentColorOrNull(): Color? {
val rgb =
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt
index 2f71918ee..d610bc497 100644
--- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt
@@ -20,12 +20,17 @@ import androidx.compose.ui.graphics.Color
import org.meshtastic.core.model.EventFirmwareEdition
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNull
+import kotlin.test.assertTrue
class EventBrandingTest {
private fun accent(hex: String?) = EventFirmwareEdition(edition = "X", accentColor = hex).accentColorOrNull()
+ private fun ended(end: String?, tz: String? = null) =
+ EventFirmwareEdition(edition = "X", eventEnd = end, timeZone = tz).hasEnded()
+
@Test
fun parsesRrggbbWithHash() {
assertEquals(Color(red = 0x00, green = 0x5D, blue = 0xAA), accent("#005DAA"))
@@ -42,4 +47,27 @@ class EventBrandingTest {
assertNull(accent("#12345")) // too short
assertNull(accent("#GGGGGG")) // not hex
}
+
+ @Test
+ fun hasEndedTrueForPastDate() {
+ assertTrue(ended("2000-01-01"))
+ assertTrue(ended("2000-01-01", tz = "America/New_York"))
+ }
+
+ @Test
+ fun hasEndedFalseForFutureDate() {
+ assertFalse(ended("9999-01-01"))
+ }
+
+ @Test
+ fun hasEndedFalseWhenEndDateMissingOrUnparseable() {
+ assertFalse(ended(null))
+ assertFalse(ended("not-a-date"))
+ }
+
+ @Test
+ fun hasEndedFallsBackToSystemZoneWhenTimeZoneUnparseable() {
+ // Bad IANA id must not throw — it falls back to the device zone, and a long-past date is still ended.
+ assertTrue(ended("2000-01-01", tz = "Not/AZone"))
+ }
}
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
index 50bc0837d..0a535530a 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
@@ -60,6 +60,8 @@ import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_disabled
import org.meshtastic.core.resources.connections
+import org.meshtastic.core.resources.firmware_event_ended_banner
+import org.meshtastic.core.resources.firmware_event_ended_button
import org.meshtastic.core.resources.firmware_recovery_banner
import org.meshtastic.core.resources.firmware_recovery_button
import org.meshtastic.core.resources.firmware_recovery_dismiss
@@ -77,7 +79,9 @@ import org.meshtastic.core.ui.icon.Bluetooth
import org.meshtastic.core.ui.icon.Language
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
+import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.PermissionStatus
+import org.meshtastic.core.ui.util.hasEnded
import org.meshtastic.core.ui.util.isBluetoothDisabled
import org.meshtastic.core.ui.util.isWifiUnavailable
import org.meshtastic.core.ui.util.rememberBluetoothPermissionState
@@ -319,6 +323,23 @@ fun ConnectionsScreen(
)
}
+ // Once an event is over, nudge users still on that event's firmware back to standard
+ // firmware. Driven purely by the metadata end date (LocalEventBranding is only populated
+ // while connected to event firmware), so it appears whenever an ended-event device is
+ // connected and disappears on its own once the device is re-flashed to vanilla. Not
+ // dismissable — it stays until the underlying condition is actually resolved.
+ LocalEventBranding.current
+ ?.takeIf { it.hasEnded() }
+ ?.let { endedEvent ->
+ Spacer(modifier = Modifier.height(8.dp))
+ RecoveryCard(
+ message =
+ stringResource(Res.string.firmware_event_ended_banner, endedEvent.displayName),
+ actionLabel = stringResource(Res.string.firmware_event_ended_button),
+ onAction = { onConfigNavigate(FirmwareRoute.FirmwareUpdate) },
+ )
+ }
+
// Region warning sits outside the animated card so it does not affect the
// CONNECTED ↔ CONNECTING ↔ NO_DEVICE size transition.
val isPhysicalDevice =
diff --git a/schemas/event_firmware.schema.json b/schemas/event_firmware.schema.json
deleted file mode 100644
index a071380da..000000000
--- a/schemas/event_firmware.schema.json
+++ /dev/null
@@ -1,94 +0,0 @@
-{
- "$schema": "https://json-schema.org/draft/2020-12/schema",
- "$id": "https://meshtastic.org/schemas/event_firmware.schema.json",
- "title": "Event Firmware Metadata",
- "description": "Offline-first display metadata for event-specific firmware editions. The bundled androidApp/src/main/assets/event_firmware.json shares the {version, generatedAt, source, []} envelope used by device_links.json so a future Meshtastic API (planned GET /resource/eventFirmware) can reuse the same shape. Each record identifies its edition by the FirmwareEdition proto enum name in the `edition` field. This schema is the authoring contract for the bundled file and is bumped as the shape evolves; the runtime JSON parser is independently tolerant of unknown fields.",
- "type": "object",
- "required": ["version", "editions"],
- "additionalProperties": false,
- "properties": {
- "version": {
- "type": "integer",
- "minimum": 1,
- "description": "Schema/envelope version. Bump when the structure changes incompatibly."
- },
- "generatedAt": {
- "type": "string",
- "format": "date-time",
- "description": "ISO-8601 timestamp the snapshot was generated."
- },
- "source": {
- "type": "string",
- "description": "Origin of this snapshot, e.g. \"bundled\" or the API URL it was frozen from."
- },
- "editions": {
- "type": "array",
- "description": "One record per event firmware edition.",
- "items": { "$ref": "#/$defs/edition" }
- }
- },
- "$defs": {
- "edition": {
- "type": "object",
- "required": ["edition", "displayName", "welcomeMessage"],
- "additionalProperties": false,
- "properties": {
- "edition": {
- "type": "string",
- "pattern": "^[A-Z0-9_]+$",
- "description": "FirmwareEdition proto enum name, e.g. \"HAMVENTION\". A pattern (not a fixed enum list) so adding a new event needs no schema change."
- },
- "displayName": {
- "type": "string",
- "description": "Human-readable name shown in the UI, e.g. \"Hamvention\" (vs the enum name \"HAMVENTION\")."
- },
- "welcomeMessage": {
- "type": "string",
- "description": "Plain (English) welcome string. NOTE: this is intentionally NOT localized — moving it into this data file drops the Crowdin per-language translation the app's string resources previously provided. Revisit when an upstream API / localization strategy is added."
- },
- "eventStart": {
- "type": ["string", "null"],
- "format": "date",
- "description": "ISO-8601 date (YYYY-MM-DD) the event begins, interpreted in timeZone. Seed values are best-effort and change yearly; the upstream sync is expected to own them."
- },
- "eventEnd": {
- "type": ["string", "null"],
- "format": "date",
- "description": "ISO-8601 date (YYYY-MM-DD) the event ends, interpreted in timeZone."
- },
- "timeZone": {
- "type": ["string", "null"],
- "description": "IANA time zone id (e.g. \"America/New_York\") so eventStart/eventEnd resolve in the event's local time, not the device's."
- },
- "location": {
- "type": ["string", "null"],
- "description": "Free-text venue/city for display, e.g. \"Las Vegas, Nevada, USA\"."
- },
- "iconUrl": {
- "type": ["string", "null"],
- "format": "uri",
- "description": "URL of the event branding icon. Null when no hosted icon exists yet."
- },
- "accentColor": {
- "type": ["string", "null"],
- "pattern": "^#[0-9A-Fa-f]{6}$",
- "description": "Brand/accent color (#RRGGBB) for ambient event theming."
- },
- "links": {
- "type": "array",
- "description": "Labeled links for the event (website, schedule, map, …).",
- "items": { "$ref": "#/$defs/link" }
- }
- }
- },
- "link": {
- "type": "object",
- "required": ["label", "url"],
- "additionalProperties": false,
- "properties": {
- "label": { "type": "string", "description": "Display text for the link." },
- "url": { "type": "string", "format": "uri", "description": "Destination URL." }
- }
- }
- }
-}