From d38dffb67a2a243f6b8f6c8fd482b3693bf85ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Da=C5=A1i=C4=87?= Date: Sun, 16 Feb 2025 21:00:19 +0100 Subject: [PATCH 1/8] Sync Serbian translations --- Localizable.xcstrings | 454 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 388 insertions(+), 66 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index e061cceb..cec96800 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -2,7 +2,7 @@ "sourceLanguage" : "en", "strings" : { "" : { - + "shouldTranslate" : false }, "\t%@" : { "localizations" : { @@ -302,8 +302,8 @@ "localizations" : { "sr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ може имати до %2$@ бајтова." + "state" : "translated", + "value" : "%@ може имати до %@ бајтова." } }, "zh-Hans" : { @@ -462,7 +462,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "%@ Апликација ће се аутоматски поново повезати са жељеним радиом ако се врати у домет." } }, @@ -521,8 +521,8 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "%@ Ова грешка обично не може да се поправи без заборављања уређаја испод подешавања > Блутут и поново повезивање са радиом." + "state" : "translated", + "value" : "%@ Ова грешка обично не може да се поправи без заборављања уређаја под Подешавања > Блутут и поново повезивање са радиом." } }, "zh-Hans" : { @@ -616,10 +616,24 @@ } }, "%@mA" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@mA" + } + } + } }, "%@V" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@V" + } + } + } }, "%d" : { "localizations" : { @@ -914,7 +928,7 @@ "localizations" : { "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "2.4 GHz" } } @@ -1059,10 +1073,24 @@ } }, "About" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Детаљи" + } + } + } }, "About Meshtastic" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "О Мештастику" + } + } + } }, "Accuracy %@" : { "localizations" : { @@ -1376,7 +1404,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "пре" } }, @@ -1434,7 +1462,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Време емитовања" } }, @@ -1723,7 +1751,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Увек укључен" } }, @@ -1797,7 +1825,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Амбијентално осветљење" } }, @@ -1855,7 +1883,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Подешавања амбијенталног осветљења" } }, @@ -2069,7 +2097,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Да ли си сигуран?" } }, @@ -2092,7 +2120,7 @@ "localizations" : { "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Аустралија / Нови Зеланд" } } @@ -2170,7 +2198,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Доступни радио уређаји" } }, @@ -2314,7 +2342,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Ниво батерије" } }, @@ -2404,7 +2432,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "BLE назив" } }, @@ -2462,7 +2490,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "BLE пин мора имати 6 цифара." } }, @@ -2610,7 +2638,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Блутут подешавања" } }, @@ -2668,7 +2696,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Блутут је искључен" } }, @@ -2791,7 +2819,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Бајтова" } }, @@ -2881,7 +2909,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Откажи" } }, @@ -2939,7 +2967,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Унапред припремљене поруке" } }, @@ -2997,7 +3025,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Подешавања унапред припремљених порука" } }, @@ -3243,22 +3271,64 @@ } }, "Ch1 Current" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch1 струја" + } + } + } }, "Ch1 Voltage" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch1 напон" + } + } + } }, "Ch2 Current" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch2 струја" + } + } + } }, "Ch2 Voltage" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch2 напон" + } + } + } }, "Ch3 Current" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch3 струја" + } + } + } }, "Ch3 Voltage" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch3 напон" + } + } + } }, "Channel" : { "localizations" : { @@ -3300,7 +3370,7 @@ }, "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Канал" } }, @@ -3335,7 +3405,14 @@ } }, "Channel 1" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 1" + } + } + } }, "Channel 1 Included" : { "localizations" : { @@ -3354,7 +3431,14 @@ } }, "Channel 2" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 2" + } + } + } }, "Channel 2 Included" : { "localizations" : { @@ -3373,7 +3457,14 @@ } }, "Channel 3" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 3" + } + } + } }, "Channel 3 Included" : { "localizations" : { @@ -3520,7 +3611,14 @@ } }, "Channel URL" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL канала" + } + } + } }, "Channel Utilization %@%% " : { "localizations" : { @@ -3908,7 +4006,7 @@ "localizations" : { "sr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Кина" } } @@ -3931,7 +4029,14 @@ } }, "Clear Log" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очисти логове" + } + } + } }, "clear.app.data" : { "localizations" : { @@ -4180,7 +4285,14 @@ } }, "Communicating" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комуницирање" + } + } + } }, "Config" : { "localizations" : { @@ -6209,7 +6321,14 @@ } }, "Current" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Струја" + } + } + } }, "Current Firmware Version: %@" : { "localizations" : { @@ -6608,7 +6727,14 @@ } }, "Delete Power metrics?" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обрисати метрику снаге?" + } + } + } }, "Description" : { "localizations" : { @@ -7757,6 +7883,12 @@ "state" : "translated", "value" : "Router Late" } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рутер са кашњењем" + } } } }, @@ -8085,6 +8217,12 @@ "state" : "translated", "value" : "Infrastructure node that always rebroadcasts packets once but only after all other modes, ensuring additional coverage for local clusters. Visible in Nodes list." } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инфраструктурни чвор који увек поново емитује пакете једном, али тек након свих осталих модова, обезбеђујући додатну покривеност за локалне кластере. Видљив у листи чворова." + } } } }, @@ -20588,7 +20726,14 @@ } }, "Navigate to node" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пређите на чвор" + } + } + } }, "Nearby Topics" : { "localizations" : { @@ -21026,7 +21171,14 @@ } }, "No Power Metrics" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нема метрике снаге" + } + } + } }, "no.nodes" : { "extractionState" : "manual", @@ -23107,7 +23259,14 @@ } }, "Power" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Снага" + } + } + } }, "Power Metrics" : { "localizations" : { @@ -23120,10 +23279,24 @@ } }, "Power Metrics Log" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дневник метрика снаге" + } + } + } }, "Power Metrics Log}" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дневник метрика снаге}" + } + } + } }, "Power Off" : { "localizations" : { @@ -23163,6 +23336,12 @@ "state" : "translated", "value" : "Delete all power metrics?" } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обрисати све метрике снаге?" + } } } }, @@ -23174,6 +23353,12 @@ "state" : "translated", "value" : "Power Metrics Log" } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дневник метрика снаге" + } } } }, @@ -24872,7 +25057,14 @@ } }, "Route Recorder" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Снимач рута" + } + } + } }, "Route recording paused" : { "localizations" : { @@ -24921,7 +25113,14 @@ } }, "Routes" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Руте" + } + } + } }, "routes.activitytype.biking" : { "extractionState" : "migrated", @@ -26479,7 +26678,14 @@ } }, "Save Channel Settings" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сачувај подешавања канала" + } + } + } }, "Save User Config to %@?" : { "localizations" : { @@ -26765,7 +26971,14 @@ } }, "Select Channel" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Одабери канал" + } + } + } }, "select.contact" : { "extractionState" : "manual", @@ -26929,10 +27142,24 @@ } }, "Send ${messageContent} to ${nodeNumber}" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пошаљи ${messageContent} на ${nodeNumber}" + } + } + } }, "Send a Direct Message" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пошаљи директну поруку" + } + } + } }, "Send a Group Message" : { "localizations" : { @@ -26961,7 +27188,14 @@ } }, "Send a message to a certain meshtastic node" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пошаљите поруку одређеном Meshtastic чвору" + } + } + } }, "Send a position on the primary channel when the user button is triple clicked." : { "localizations" : { @@ -28655,6 +28889,12 @@ }, "Store & Forward" : { "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чување и прослеђивање" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -28665,6 +28905,12 @@ }, "Store & Forward Config" : { "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подешавања чувања и прослеђивања" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -28764,6 +29010,12 @@ "state" : "translated", "value" : "מחובר למש" } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Претплаћен" + } } } }, @@ -28827,7 +29079,14 @@ } }, "Takes a Meshtastic channel URL and saves the channel settings." : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Преузима URL Meshtastic канала и чува подешавања канала" + } + } + } }, "Tapback" : { "localizations" : { @@ -30025,7 +30284,14 @@ } }, "The URL for the channel settings" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"URL за подешавања канала" + } + } + } }, "There has been no response to a request for device metadata over the admin channel for this node." : { "localizations" : { @@ -30280,7 +30546,14 @@ } }, "timestamp" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "временска ознака" + } + } + } }, "Timestamp" : { "localizations" : { @@ -31340,7 +31613,14 @@ } }, "unknown" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "непознато" + } + } + } }, "Unknown" : { "localizations" : { @@ -31775,7 +32055,14 @@ } }, "Uptime" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Време рада" + } + } + } }, "Use a PWM output (like the RAK Buzzer) for tunes instead of an on/off output. This will ignore the output, output duration and active settings and use the device config buzzer GPIO option instead." : { "localizations" : { @@ -31836,7 +32123,14 @@ } }, "Used to create a shared key with a remote device." : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Користи се за креирање заједничког кључа са удаљеним уређајем" + } + } + } }, "user" : { "localizations" : { @@ -32260,7 +32554,14 @@ } }, "Voltage" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Напон" + } + } + } }, "Volts %@ " : { "localizations" : { @@ -32503,7 +32804,14 @@ } }, "Whether or not use INPUT_PULLUP mode for GPIO pin. Only applicable if the board uses pull-up resistors on the pin" : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Да ли користити INPUT_PULLUP мод за GPIO пин. Применљиво само ако плоча користи pull-up отпорнике на пину" + } + } + } }, "WiFi Options" : { "localizations" : { @@ -32732,10 +33040,24 @@ } }, "Your MQTT Server must support TLS." : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш MQTT сервер мора подржавати TLS" + } + } + } }, "Your node’s operating frequency is calculated based on the region, modem preset, and this field. When 0, the slot is automatically calculated based on the primary channel name." : { - + "localizations" : { + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Радна фреквенција вашег чвора се израчунава на основу региона, поставки модема и овог поља. Када је вредност 0, слот се аутоматски израчунава на основу примарног имена канала." + } + } + } }, "Your position has been sent with a request for a response with their position. You will receive a notification when a position is returned." : { "localizations" : { From 1abe1bf6ac889881182b11f96708f840ffe6cead Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 18 Feb 2025 12:14:59 -0800 Subject: [PATCH 2/8] Added markMessageASRead func --- .../Views/Messages/ChannelMessageList.swift | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 573a7dc8..276f6fbf 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -112,16 +112,8 @@ struct ChannelMessageList: View { .frame(maxWidth: .infinity) .id(message.messageId) .onAppear { - if !message.read { - message.read = true - do { - try context.save() - Logger.data.info("📖 [App] Read message \(message.messageId) ") - appState.unreadChannelMessages = myInfo.unreadMessages - context.refresh(myInfo, mergeChanges: true) - } catch { - Logger.data.error("Failed to read message \(message.messageId): \(error.localizedDescription)") - } + Task { + await markMessageAsRead(message) } } } @@ -178,4 +170,21 @@ struct ChannelMessageList: View { } } } + + @MainActor + func markMessageAsRead(_ message: MessageEntity) async { + guard !message.read else { return } + + message.read = true + + do { + try await Task.sleep(nanoseconds: 300_000_000) // 300ms debounce + try context.save() + Logger.data.info("📖 [App] Read message \(message.messageId)") + appState.unreadChannelMessages = myInfo.unreadMessages + context.refresh(myInfo, mergeChanges: true) + } catch { + Logger.data.error("Failed to read message \(message.messageId): \(error.localizedDescription)") + } + } } From bc4687953e7e2f2dd489788993bf45aeb1be2d4f Mon Sep 17 00:00:00 2001 From: Jake-B Date: Fri, 21 Feb 2025 18:23:03 -0500 Subject: [PATCH 3/8] Refactor to optional values for TelemetryEntity attributes --- Localizable.xcstrings | 71 +++---- Meshtastic.xcodeproj/project.pbxproj | 22 +++ Meshtastic/Export/WriteCsvFile.swift | 48 ++--- Meshtastic/Extensions/Constants.swift | 6 +- .../ManagedAttributePropertyWrapper.swift | 61 ++++++ .../CoreData/NodeInfoEntityExtension.swift | 13 ++ Meshtastic/Helpers/MeshPackets.swift | 85 ++++----- .../contents | 4 +- .../TelemetryEntity+CoreDataClass.swift | 46 +++++ .../TelemetryEntity+CoreDataProperties.swift | 36 ++++ .../MetricTableColumn.swift | 14 +- .../MetricsChartSeries.swift | 25 ++- .../MetricsColumnList.swift | 4 +- Meshtastic/Views/Helpers/BatteryCompact.swift | 95 +++++----- Meshtastic/Views/Helpers/PowerMetrics.swift | 24 +-- .../Weather/LocalWeatherConditions.swift | 40 ++-- Meshtastic/Views/Nodes/DeviceMetricsLog.swift | 107 ++++++----- .../Views/Nodes/EnvironmentMetricsLog.swift | 14 +- .../EnviornmentDefaultSeries.swift | 173 ++++++++++-------- .../EnvironmentDefaultColumns.swift | 80 +++++--- .../Views/Nodes/Helpers/NodeDetail.swift | 40 ++-- Meshtastic/Views/Nodes/PowerMetricsLog.swift | 64 ++++--- Widgets/MeshActivityAttributes.swift | 6 +- Widgets/WidgetsLiveActivity.swift | 29 +-- 24 files changed, 682 insertions(+), 425 deletions(-) create mode 100644 Meshtastic/Extensions/CoreData/ManagedAttributePropertyWrapper.swift create mode 100644 Meshtastic/Model/CoreData/TelemetryEntity+CoreDataClass.swift create mode 100644 Meshtastic/Model/CoreData/TelemetryEntity+CoreDataProperties.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index e061cceb..92bf62b7 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -36,6 +36,12 @@ } } }, + " %@%%" : { + + }, + "--" : { + "shouldTranslate" : false + }, ": %@" : { "localizations" : { "sr" : { @@ -67,6 +73,9 @@ } } } + }, + "?" : { + }, "(Re)define PIN_GPS_EN for your board." : { "localizations" : { @@ -728,6 +737,9 @@ } } } + }, + "%f%%" : { + }, "%lf" : { "localizations" : { @@ -1452,22 +1464,6 @@ } } }, - "Airtime %@%%" : { - "localizations" : { - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Време емитовања %@%%" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "广播时间 %@%%" - } - } - } - }, "Alert" : { "localizations" : { "sr" : { @@ -3522,7 +3518,7 @@ "Channel URL" : { }, - "Channel Utilization %@%% " : { + "Channel Utilization %@%%" : { "localizations" : { "sr" : { "stringUnit" : { @@ -12026,22 +12022,6 @@ } } }, - "HUMIDITY" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "LUFTFEUCHTIGKEIT" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ВЛАЖНОСТ" - } - } - } - }, "hybrid" : { "extractionState" : "migrated", "localizations" : { @@ -23300,18 +23280,18 @@ } } }, - "PRESSURE" : { + "Pressure" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "DRUCK" + "value" : "Druck" } }, "sr" : { "stringUnit" : { "state" : "translated", - "value" : "ПРИТИСАК" + "value" : "Притисак" } } } @@ -32195,7 +32175,7 @@ } } }, - "voltage" : { + "Voltage" : { "localizations" : { "de" : { "stringUnit" : { @@ -32259,10 +32239,7 @@ } } }, - "Voltage" : { - - }, - "Volts %@ " : { + "Volts %@" : { "localizations" : { "de" : { "stringUnit" : { @@ -32521,18 +32498,12 @@ } } }, - "WIND" : { + "Wind" : { "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "WIND" - } - }, "sr" : { "stringUnit" : { "state" : "translated", - "value" : "ВЕТАР" + "value" : "Ветар" } } } @@ -32791,4 +32762,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 489b0e85..b395a2ad 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -11,6 +11,10 @@ 231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; }; 231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */; }; 231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */; }; + 2344A2AB2D66974300170A77 /* ManagedAttributePropertyWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */; }; + 2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */; }; + 2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */; }; + 2344A2B12D68DFF800170A77 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C49D8F2C471AEA0024FBD1 /* Constants.swift */; }; 2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; }; 2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; }; 2373AE172D0A26620086C749 /* EnviornmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */; }; @@ -275,6 +279,9 @@ 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = ""; }; 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = ""; }; 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = ""; }; + 2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedAttributePropertyWrapper.swift; sourceTree = ""; }; + 2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataClass.swift"; sourceTree = ""; }; + 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataProperties.swift"; sourceTree = ""; }; 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = ""; }; 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = ""; }; 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnviornmentDefaultSeries.swift; sourceTree = ""; }; @@ -603,6 +610,15 @@ path = "Metrics Columns"; sourceTree = ""; }; + 2344A2AC2D66978000170A77 /* CoreData */ = { + isa = PBXGroup; + children = ( + 2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */, + 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */, + ); + path = CoreData; + sourceTree = ""; + }; 251926882C3BAF2E00249DF5 /* Actions */ = { isa = PBXGroup; children = ( @@ -688,6 +704,7 @@ DD007BB12AA59B9A00F5FA12 /* CoreData */ = { isa = PBXGroup; children = ( + 2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */, DD58C5F12919AD3C00D5BEFB /* ChannelEntityExtension.swift */, 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */, DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */, @@ -989,6 +1006,7 @@ DDC2E18826CE24EE0042C5E4 /* Model */ = { isa = PBXGroup; children = ( + 2344A2AC2D66978000170A77 /* CoreData */, 231B3F1E2D0879BC0069A07D /* Metrics Visualization */, DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */, ); @@ -1542,12 +1560,15 @@ DD6F657B2C6EC2900053C113 /* LockLegend.swift in Sources */, DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */, DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */, + 2344A2AB2D66974300170A77 /* ManagedAttributePropertyWrapper.swift in Sources */, BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */, D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */, DDA9515E2BC6F56F00CEA535 /* IndoorAirQuality.swift in Sources */, DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */, DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */, DDC4C9FF2A8D982900CE201C /* DetectionSensorConfig.swift in Sources */, + 2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */, + 2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */, D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */, DDDB26442AAC0206003AFCB7 /* NodeDetail.swift in Sources */, DD77093F2AA1B146007A8BF0 /* UIColor.swift in Sources */, @@ -1570,6 +1591,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2344A2B12D68DFF800170A77 /* Constants.swift in Sources */, DDC94FC229CE063B0082EA6E /* BatteryLevel.swift in Sources */, DDDE5A1129AFE69700490C6C /* MeshActivityAttributes.swift in Sources */, DDDE59FB29AF163D00490C6C /* WidgetsLiveActivity.swift in Sources */, diff --git a/Meshtastic/Export/WriteCsvFile.swift b/Meshtastic/Export/WriteCsvFile.swift index eb424586..66835b4a 100644 --- a/Meshtastic/Export/WriteCsvFile.swift +++ b/Meshtastic/Export/WriteCsvFile.swift @@ -14,35 +14,35 @@ func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") if metricsType == 0 { // Create Device Metrics Header - csvString = "\("battery.level".localized), \("voltage".localized), \("channel.utilization".localized), \("airtime".localized), \("uptime".localized), \("Timestamp".localized)" + csvString = "\("battery.level".localized), \("Voltage".localized), \("channel.utilization".localized), \("airtime".localized), \("uptime".localized), \("Timestamp".localized)" for dm in telemetry where dm.metricsType == 0 { - csvString += "\n" - csvString += String(dm.batteryLevel) - csvString += ", " - csvString += String(dm.voltage) - csvString += ", " - csvString += String(dm.channelUtilization) - csvString += ", " - csvString += String(dm.airUtilTx) - csvString += ", " - csvString += String(dm.uptimeSeconds) - csvString += ", " - csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized + csvString += "\n" + csvString += dm.batteryLevel?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.voltage?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.channelUtilization?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.airUtilTx?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.uptimeSeconds?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized } } else if metricsType == 1 { // Create Environment Telemetry Header csvString = "Temperature, Relative Humidity, Barometric Pressure, Indoor Air Quality, Gas Resistance, \("Timestamp".localized)" for dm in telemetry where dm.metricsType == 1 { csvString += "\n" - csvString += String(dm.temperature.localeTemperature()) + csvString += dm.temperature?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.relativeHumidity) + csvString += dm.relativeHumidity?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.barometricPressure) + csvString += dm.barometricPressure?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.iaq) + csvString += dm.iaq?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.gasResistance) + csvString += dm.gasResistance?.formatted(.number.grouping(.never)) ?? "" csvString += ", " csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized } @@ -51,17 +51,17 @@ func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin csvString = "Channel 1 Voltage, Channel 1 Current, Channel 2 Voltage, Channel 2 Current, Channel 3 Voltage, Channel 3 Current, \("Timestamp".localized)" for dm in telemetry where dm.metricsType == 2 { csvString += "\n" - csvString += String(dm.powerCh1Voltage) + csvString += dm.powerCh1Voltage?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.powerCh1Current) + csvString += dm.powerCh1Current?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.powerCh2Voltage) + csvString += dm.powerCh2Voltage?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.powerCh2Current) + csvString += dm.powerCh2Current?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.powerCh3Voltage) + csvString += dm.powerCh3Voltage?.formatted(.number.grouping(.never)) ?? "" csvString += ", " - csvString += String(dm.powerCh3Current) + csvString += dm.powerCh3Current?.formatted(.number.grouping(.never)) ?? "" csvString += ", " csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized } diff --git a/Meshtastic/Extensions/Constants.swift b/Meshtastic/Extensions/Constants.swift index 03a3cc31..7b6288a2 100644 --- a/Meshtastic/Extensions/Constants.swift +++ b/Meshtastic/Extensions/Constants.swift @@ -1,5 +1,5 @@ import Foundation - +import SwiftUI enum Constants { /// `UInt32.max` or FFFF,FFFF in hex is used to identify messages that are being /// sent to a channel and are not a DM to an individual user. This is used @@ -8,4 +8,8 @@ enum Constants { /// Based on the NUM_RESERVED from the firmware. /// https://github.com/meshtastic/firmware/blob/46d7b82ac1a4292ba52ca690e1a433d3a501a9e5/src/mesh/NodeDB.cpp#L522 static let minimumNodeNum = 4 + + // String used to render a nil value. If changed, mark the new entry in + // Localizable.xcstrings as "do not translate" and remove the old key. + static let nilValueIndicator = "--" } diff --git a/Meshtastic/Extensions/CoreData/ManagedAttributePropertyWrapper.swift b/Meshtastic/Extensions/CoreData/ManagedAttributePropertyWrapper.swift new file mode 100644 index 00000000..3b123207 --- /dev/null +++ b/Meshtastic/Extensions/CoreData/ManagedAttributePropertyWrapper.swift @@ -0,0 +1,61 @@ +// +// ManagedAttributePropertyWrapper.swift +// Meshtastic +// +// Created by Jake Bordens on 12/26/24. +// +import CoreData + +@propertyWrapper +public struct ManagedAttribute { + private let attributeName: String + private let converter: (NSNumber) -> Value? + + public init(attributeName: String) { + self.attributeName = attributeName + + // Define the converter closure based on the generic type Value + if Value.self == Float.self { + converter = { $0.floatValue as? Value } + } else if Value.self == Double.self { + converter = { $0.doubleValue as? Value } + } else if Value.self == Int.self { + converter = { $0.intValue as? Value } + } else if Value.self == Int8.self { + converter = { $0.int8Value as? Value } + } else if Value.self == Int16.self { + converter = { $0.int16Value as? Value } + } else if Value.self == Int32.self { + converter = { $0.int32Value as? Value } + } else if Value.self == Int64.self { + converter = { $0.int64Value as? Value } + } else { + fatalError("Unsupported type: \(Value.self)") + } + } + + public var wrappedValue: Value? { + get { fatalError("Access via enclosing instance required.") } + set { fatalError("Access via enclosing instance required.") } + } + + public static subscript( + _enclosingInstance observed: EnclosingSelf, + wrapped wrappedKeyPath: KeyPath, + storage storageKeyPath: ReferenceWritableKeyPath> + ) -> Value? { + get { + let wrapper = observed[keyPath: storageKeyPath] + let number = observed.primitiveValue(forKey: wrapper.attributeName) as? NSNumber + return number.flatMap { wrapper.converter($0) } + } + set { + let wrapper = observed[keyPath: storageKeyPath] + if let newValue = newValue { + observed.setPrimitiveValue(NSNumber(value: Double("\(newValue)")!), forKey: wrapper.attributeName) + } else { + observed.setPrimitiveValue(nil, forKey: wrapper.attributeName) + } + } + } +} diff --git a/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift b/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift index b1bbb8c6..bed6d970 100644 --- a/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift @@ -39,6 +39,19 @@ extension NodeInfoEntity { let environmentMetrics = telemetries?.filter { ($0 as AnyObject).metricsType == 1 } return environmentMetrics?.count ?? 0 > 0 } + + func hasDataForLatestEnvironmentMetrics(attributes: [String]) -> Bool { + for attribute in attributes { + guard self.latestEnvironmentMetrics?.entity.attributesByName.keys.contains(attribute) ?? false else { + return false + } + if self.latestEnvironmentMetrics?.value(forKey: attribute) != nil { + return true + } + } + return false + } + var hasDetectionSensorMetrics: Bool { return user?.sensorMessageList.count ?? 0 > 0 } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 8c85751f..8ab6dc97 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -15,6 +15,13 @@ import OSLog import ActivityKit #endif +// Simple extension to consicely pass values through a has_XXX boolean check +fileprivate extension Bool { + func then(_ value: T) -> T? { + self ? value : nil + } +} + func generateMessageMarkdown (message: String) -> String { if !message.isEmoji() { let types: NSTextCheckingResult.CheckingType = [.address, .link, .phoneNumber] @@ -698,28 +705,28 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage /// Currently only Device Metrics and Environment Telemetry are supported in the app if telemetryMessage.variant == Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) { // Device Metrics - telemetry.airUtilTx = telemetryMessage.deviceMetrics.airUtilTx - telemetry.channelUtilization = telemetryMessage.deviceMetrics.channelUtilization - telemetry.batteryLevel = Int32(telemetryMessage.deviceMetrics.batteryLevel) - telemetry.voltage = telemetryMessage.deviceMetrics.voltage - telemetry.uptimeSeconds = Int32(telemetryMessage.deviceMetrics.uptimeSeconds) + telemetry.airUtilTx = telemetryMessage.deviceMetrics.hasAirUtilTx.then(telemetryMessage.deviceMetrics.airUtilTx) + telemetry.channelUtilization = telemetryMessage.deviceMetrics.hasChannelUtilization.then(telemetryMessage.deviceMetrics.channelUtilization) + telemetry.batteryLevel = telemetryMessage.deviceMetrics.hasBatteryLevel.then(Int32(telemetryMessage.deviceMetrics.batteryLevel)) + telemetry.voltage = telemetryMessage.deviceMetrics.hasVoltage.then(telemetryMessage.deviceMetrics.voltage) + telemetry.uptimeSeconds = telemetryMessage.deviceMetrics.hasUptimeSeconds.then(Int32(telemetryMessage.deviceMetrics.uptimeSeconds)) telemetry.metricsType = 0 Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.deviceMetrics.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.deviceMetrics.airUtilTx, privacy: .public) for Node: \(packet.from.toHex(), privacy: .public)") } else if telemetryMessage.variant == Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) { // Environment Metrics - telemetry.barometricPressure = telemetryMessage.environmentMetrics.barometricPressure - telemetry.current = telemetryMessage.environmentMetrics.current - telemetry.iaq = Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq) - telemetry.gasResistance = telemetryMessage.environmentMetrics.gasResistance - telemetry.relativeHumidity = telemetryMessage.environmentMetrics.relativeHumidity - telemetry.temperature = telemetryMessage.environmentMetrics.temperature - telemetry.current = telemetryMessage.environmentMetrics.current - telemetry.voltage = telemetryMessage.environmentMetrics.voltage - telemetry.weight = telemetryMessage.environmentMetrics.weight - telemetry.windSpeed = telemetryMessage.environmentMetrics.windSpeed - telemetry.windGust = telemetryMessage.environmentMetrics.windGust - telemetry.windLull = telemetryMessage.environmentMetrics.windLull - telemetry.windDirection = Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection) + telemetry.barometricPressure = telemetryMessage.environmentMetrics.hasBarometricPressure.then(telemetryMessage.environmentMetrics.barometricPressure) + telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current) + telemetry.iaq = telemetryMessage.environmentMetrics.hasIaq.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq)) + telemetry.gasResistance = telemetryMessage.environmentMetrics.hasGasResistance.then(telemetryMessage.environmentMetrics.gasResistance) + telemetry.relativeHumidity = telemetryMessage.environmentMetrics.hasRelativeHumidity.then(telemetryMessage.environmentMetrics.relativeHumidity) + telemetry.temperature = telemetryMessage.environmentMetrics.hasTemperature.then(telemetryMessage.environmentMetrics.temperature) + telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current) + telemetry.voltage = telemetryMessage.environmentMetrics.hasVoltage.then(telemetryMessage.environmentMetrics.voltage) + telemetry.weight = telemetryMessage.environmentMetrics.hasWeight.then(telemetryMessage.environmentMetrics.weight) + telemetry.windSpeed = telemetryMessage.environmentMetrics.hasWindSpeed.then(telemetryMessage.environmentMetrics.windSpeed) + telemetry.windGust = telemetryMessage.environmentMetrics.hasWindGust.then(telemetryMessage.environmentMetrics.windGust) + telemetry.windLull = telemetryMessage.environmentMetrics.hasWindLull.then(telemetryMessage.environmentMetrics.windLull) + telemetry.windDirection = telemetryMessage.environmentMetrics.hasWindDirection.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection)) telemetry.metricsType = 1 } else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { // Local Stats for Live activity @@ -739,35 +746,14 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage } else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { Logger.data.info("📈 [Power Metrics] Received for Node: \(packet.from.toHex(), privacy: .public)") - if telemetryMessage.powerMetrics.hasCh1Voltage { - telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.ch1Voltage - telemetry.metricsType = 2 - } + telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage) + telemetry.powerCh1Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch1Current) + telemetry.powerCh2Voltage = telemetryMessage.powerMetrics.hasCh2Voltage.then(telemetryMessage.powerMetrics.ch2Voltage) + telemetry.powerCh2Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch2Current) + telemetry.powerCh3Voltage = telemetryMessage.powerMetrics.hasCh3Voltage.then(telemetryMessage.powerMetrics.ch3Voltage) + telemetry.powerCh3Current = telemetryMessage.powerMetrics.hasCh3Current.then(telemetryMessage.powerMetrics.ch3Current) + telemetry.metricsType = 2 - if telemetryMessage.powerMetrics.hasCh1Current { - telemetry.powerCh1Current = telemetryMessage.powerMetrics.ch1Current - telemetry.metricsType = 2 - } - - if telemetryMessage.powerMetrics.hasCh2Voltage { - telemetry.powerCh2Voltage = telemetryMessage.powerMetrics.ch2Voltage - telemetry.metricsType = 2 - } - - if telemetryMessage.powerMetrics.hasCh1Current { - telemetry.powerCh2Current = telemetryMessage.powerMetrics.ch2Current - telemetry.metricsType = 2 - } - - if telemetryMessage.powerMetrics.hasCh3Voltage { - telemetry.powerCh3Voltage = telemetryMessage.powerMetrics.ch3Voltage - telemetry.metricsType = 2 - } - - if telemetryMessage.powerMetrics.hasCh3Current { - telemetry.powerCh3Current = telemetryMessage.powerMetrics.ch3Current - telemetry.metricsType = 2 - } } telemetry.snr = packet.rxSnr telemetry.rssi = packet.rxRssi @@ -791,14 +777,15 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage // ------------------------ // Low Battery notification if connectedNode == Int64(packet.from) { - if UserDefaults.lowBatteryNotifications && telemetry.batteryLevel > 0 && telemetry.batteryLevel < 4 { + let batteryLevel = telemetry.batteryLevel ?? 0 + if UserDefaults.lowBatteryNotifications && batteryLevel > 0 && batteryLevel < 4 { let manager = LocalNotificationManager() manager.notifications = [ Notification( id: ("notification.id.\(UUID().uuidString)"), title: "Critically Low Battery!", subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")", - content: "Time to charge your radio, there is \(telemetry.batteryLevel)% battery remaining.", + content: "Time to charge your radio, there is \(telemetry.batteryLevel?.formatted(.number) ?? Constants.nilValueIndicator)% battery remaining.", target: "nodes", path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" ) @@ -813,7 +800,7 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage let fifteenMinutesLater = Calendar.current.date(byAdding: .minute, value: (Int(15) ), to: Date())! let date = Date.now...fifteenMinutesLater - let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: UInt32(telemetry.uptimeSeconds), + let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: telemetry.uptimeSeconds.map { UInt32($0) }, channelUtilization: telemetry.channelUtilization, airtime: telemetry.airUtilTx, sentPackets: UInt32(telemetry.numPacketsTx), diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents index 5f12d9d0..9e23de8a 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 49.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -390,7 +390,7 @@ - + diff --git a/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataClass.swift b/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataClass.swift new file mode 100644 index 00000000..bcbaf4b9 --- /dev/null +++ b/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataClass.swift @@ -0,0 +1,46 @@ +// +// TelemetryEntity+CoreDataClass.swift +// +// +// Created by Jake Bordens on 12/26/24. +// +// + +import Foundation +import CoreData + +// Manual implementation of the TelemetryEntry object for CoreData. +// Add optional scalar types here using the @ManagedAttribute property wrapper. +// CoreData is based on Objective-C, which doesn't have optional scalars. +// The @ManagedAttribute property wrapper handles the conversion to optional scalars. + +@objc(TelemetryEntity) +public class TelemetryEntity: NSManagedObject, Identifiable { + + @ManagedAttribute(attributeName: "airUtilTx") public var airUtilTx: Float? + @ManagedAttribute(attributeName: "barometricPressure") public var barometricPressure: Float? + @ManagedAttribute(attributeName: "batteryLevel") public var batteryLevel: Int32? + @ManagedAttribute(attributeName: "channelUtilization") public var channelUtilization: Float? + @ManagedAttribute(attributeName: "current") public var current: Float? + @ManagedAttribute(attributeName: "distance") public var distance: Float? + @ManagedAttribute(attributeName: "gasResistance") public var gasResistance: Float? + @ManagedAttribute(attributeName: "iaq") public var iaq: Int32? + @ManagedAttribute(attributeName: "powerCh1Current") var powerCh1Current: Float? + @ManagedAttribute(attributeName: "powerCh1Voltage") var powerCh1Voltage: Float? + @ManagedAttribute(attributeName: "powerCh2Current") var powerCh2Current: Float? + @ManagedAttribute(attributeName: "powerCh2Voltage") var powerCh2Voltage: Float? + @ManagedAttribute(attributeName: "powerCh3Current") var powerCh3Current: Float? + @ManagedAttribute(attributeName: "powerCh3Voltage") var powerCh3Voltage: Float? + @ManagedAttribute(attributeName: "relativeHumidity") public var relativeHumidity: Float? + @ManagedAttribute(attributeName: "rssi") public var rssi: Int32? + @ManagedAttribute(attributeName: "snr") public var snr: Float? + @ManagedAttribute(attributeName: "temperature") public var temperature: Float? + @ManagedAttribute(attributeName: "uptimeSeconds") public var uptimeSeconds: Int32? + @ManagedAttribute(attributeName: "voltage") public var voltage: Float? + @ManagedAttribute(attributeName: "weight") public var weight: Float? + @ManagedAttribute(attributeName: "windDirection") public var windDirection: Int32? + @ManagedAttribute(attributeName: "windGust") public var windGust: Float? + @ManagedAttribute(attributeName: "windLull") public var windLull: Float? + @ManagedAttribute(attributeName: "windSpeed") public var windSpeed: Float? + +} diff --git a/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataProperties.swift b/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataProperties.swift new file mode 100644 index 00000000..278a322a --- /dev/null +++ b/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataProperties.swift @@ -0,0 +1,36 @@ +// +// TelemetryEntity+CoreDataProperties.swift +// +// +// Created by Jake Bordens on 12/26/24. +// +// + +import Foundation +import CoreData + +// Manual implementation of the TelemetryEntry object for CoreData. +// Add non-optional scalar types here using the standard @NSManaged proprty wrapper +// Add optional/non-optional object types here using the standard @NSManaged proprty wrapper +// CoreData is based on Objective-C which natively supports optionals for class types and +// non-optional scalars. + +extension TelemetryEntity { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "TelemetryEntity") + } + + @NSManaged public var time: Date? + @NSManaged public var metricsType: Int32 + @NSManaged public var numOnlineNodes: Int32 + @NSManaged public var numPacketsRx: Int32 + @NSManaged public var numPacketsRxBad: Int32 + @NSManaged public var numPacketsTx: Int32 + @NSManaged public var numRxDupe: Int32 + @NSManaged public var numTotalNodes: Int32 + @NSManaged public var numTxRelay: Int32 + @NSManaged public var numTxRelayCanceled: Int32 + @NSManaged public var nodeTelemetry: NodeInfoEntity? + +} diff --git a/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift b/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift index 188e4eba..5a3c53de 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift @@ -13,8 +13,9 @@ import SwiftUI // Given a keypath, this class holds information about how to render the attrbute in // the table. MetricsTableColumn objects are collected in a MetricsColumnList class MetricsTableColumn: ObservableObject { - // CoreData Attribute Name on TelemetryEntity - let attribute: String + // Uniquely identify this column for presistance and iteration + // Recommend using CoreData Attribute Name on TelemetryEntity + let id: String // Heading for wider tables let name: String @@ -37,6 +38,7 @@ class MetricsTableColumn: ObservableObject { // Main initializer init( + id: String, keyPath: KeyPath, name: String, abbreviatedName: String, @@ -47,7 +49,7 @@ class MetricsTableColumn: ObservableObject { @ViewBuilder tableBody: @escaping (MetricsTableColumn, Value) -> TableContent? ) { // This works because TelemetryEntity is an NSManagedObject and derrived from NSObject - self.attribute = NSExpression(forKeyPath: keyPath).keyPath + self.id = id self.name = name self.abbreviatedName = abbreviatedName self.minWidth = minWidth @@ -72,13 +74,11 @@ class MetricsTableColumn: ObservableObject { } extension MetricsTableColumn: Identifiable, Hashable { - var id: String { self.attribute } - static func == (lhs: MetricsTableColumn, rhs: MetricsTableColumn) -> Bool { - lhs.attribute == rhs.attribute + lhs.id == rhs.id } func hash(into hasher: inout Hasher) { - hasher.combine(attribute) + hasher.combine(id) } } diff --git a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift index add0318e..b535099e 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift @@ -14,8 +14,9 @@ import SwiftUI // the chart. MetricsChartSeries objects are collected in a MetricsSeriesList class MetricsChartSeries: ObservableObject { - // CoreData Attribute Name on TelemetryEntity - let attribute: String + // Uniquely identify this column for presistance and iteration + // Recommend using CoreData Attribute Name on TelemetryEntity + let id: String // Heading for areas that have the room let name: String @@ -39,6 +40,7 @@ class MetricsChartSeries: ObservableObject { // Main initializer init( + id: String, keyPath: KeyPath, name: String, abbreviatedName: String, @@ -46,10 +48,10 @@ class MetricsChartSeries: ObservableObject { visible: Bool = true, foregroundStyle: @escaping ((ClosedRange?) -> ForegroundStyle?) = { _ in nil }, @ChartContentBuilder chartBody: @escaping (MetricsChartSeries, ClosedRange?, Date, Value) -> ChartBody? - ) where Value: Plottable & Comparable { + ) { // This works because TelemetryEntity is an NSManagedObject and derrived from NSObject - self.attribute = NSExpression(forKeyPath: keyPath).keyPath + self.id = id self.name = name self.abbreviatedName = abbreviatedName self.visible = visible @@ -63,9 +65,15 @@ class MetricsChartSeries: ObservableObject { } self.valueClosure = { te in if let conversion { - return conversion(te[keyPath: keyPath]).floatValue + if let value = conversion(te[keyPath: keyPath]) as? (any Plottable) { + return value.floatValue ?? 0.0 + } + } else { + if let value = te[keyPath: keyPath] as? (any Plottable) { + return value.floatValue + } } - return te[keyPath: keyPath].floatValue + return nil } } @@ -82,14 +90,13 @@ class MetricsChartSeries: ObservableObject { } extension MetricsChartSeries: Identifiable, Hashable { - var id: String { self.attribute } static func == (lhs: MetricsChartSeries, rhs: MetricsChartSeries) -> Bool { - lhs.attribute == rhs.attribute + lhs.id == rhs.id } func hash(into hasher: inout Hasher) { - hasher.combine(attribute) + hasher.combine(id) } } diff --git a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift index ccb0b758..a40057ea 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift @@ -43,8 +43,8 @@ class MetricsColumnList: ObservableObject, RandomAccessCollection, RangeReplacea return returnValues } - func column(forAttribute attribute: String) -> MetricsTableColumn? { - return columns.first(where: { $0.attribute == attribute}) + func column(withId id: String) -> MetricsTableColumn? { + return columns.first(where: { $0.id == id}) } // Collection conformance diff --git a/Meshtastic/Views/Helpers/BatteryCompact.swift b/Meshtastic/Views/Helpers/BatteryCompact.swift index bc714da6..4ac61d0c 100644 --- a/Meshtastic/Views/Helpers/BatteryCompact.swift +++ b/Meshtastic/Views/Helpers/BatteryCompact.swift @@ -7,59 +7,72 @@ import SwiftUI struct BatteryCompact: View { - var batteryLevel: Int32 + var batteryLevel: Int32? var font: Font var iconFont: Font var color: Color var body: some View { HStack(alignment: .center, spacing: 0) { - if batteryLevel == 100 { - Image(systemName: "battery.100.bolt") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 100 && batteryLevel > 74 { - Image(systemName: "battery.75") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 75 && batteryLevel > 49 { - Image(systemName: "battery.50") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 50 && batteryLevel > 14 { - Image(systemName: "battery.25") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 15 && batteryLevel > 0 { + if let batteryLevel { + if batteryLevel == 100 { + Image(systemName: "battery.100.bolt") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 100 && batteryLevel > 74 { + Image(systemName: "battery.75") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 75 && batteryLevel > 49 { + Image(systemName: "battery.50") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 50 && batteryLevel > 14 { + Image(systemName: "battery.25") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 15 && batteryLevel > 0 { + Image(systemName: "battery.0") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel == 0 { + Image(systemName: "battery.0") + .font(iconFont) + .foregroundColor(.red) + .symbolRenderingMode(.multicolor) + } else if batteryLevel > 100 { + Image(systemName: "powerplug") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } + } else { Image(systemName: "battery.0") .font(iconFont) .foregroundColor(color) .symbolRenderingMode(.multicolor) - } else if batteryLevel == 0 { - Image(systemName: "battery.0") - .font(iconFont) - .foregroundColor(.red) - .symbolRenderingMode(.multicolor) - } else if batteryLevel > 100 { - Image(systemName: "powerplug") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) } - if batteryLevel > 100 { - Text("PWD") - .foregroundStyle(.secondary) - .font(font) - } else if batteryLevel == 100 { - Text("CHG") - .foregroundStyle(.secondary) - .font(font) + if let batteryLevel { + if batteryLevel > 100 { + Text("PWD") + .foregroundStyle(.secondary) + .font(font) + } else if batteryLevel == 100 { + Text("CHG") + .foregroundStyle(.secondary) + .font(font) + } else { + Text(verbatim: "\(batteryLevel.formatted(.number.precision(.fractionLength(0))))%") + .foregroundStyle(.secondary) + .font(font) + } } else { - Text("\(batteryLevel)%") + Text(verbatim: "?") .foregroundStyle(.secondary) .font(font) } diff --git a/Meshtastic/Views/Helpers/PowerMetrics.swift b/Meshtastic/Views/Helpers/PowerMetrics.swift index cbff60a2..7f9fdcdd 100644 --- a/Meshtastic/Views/Helpers/PowerMetrics.swift +++ b/Meshtastic/Views/Helpers/PowerMetrics.swift @@ -17,50 +17,50 @@ struct PowerMetrics: View { LazyVGrid(columns: gridItemLayout) { - if metric.powerCh1Voltage != nil { + if let powerCh1Voltage = metric.powerCh1Voltage { PowerMetricCompactWidget( type: .voltage, - value: metric.powerCh1Voltage, + value: powerCh1Voltage, title: "Channel 1 Voltage" ) } - if metric.powerCh1Current != nil { + if let powerCh1Current = metric.powerCh1Current { PowerMetricCompactWidget( type: .current, - value: metric.powerCh1Current, + value: powerCh1Current, title: "Channel 1 Current" ) } - if metric.powerCh2Voltage != nil { + if let powerCh2Voltage = metric.powerCh2Voltage { PowerMetricCompactWidget( type: .voltage, - value: metric.powerCh2Voltage, + value: powerCh2Voltage, title: "Channel 2 Voltage" ) } - if metric.powerCh2Current != nil { + if let powerCh2Current = metric.powerCh2Current { PowerMetricCompactWidget( type: .current, - value: metric.powerCh2Current, + value: powerCh2Current, title: "Channel 2 Current" ) } - if metric.powerCh3Voltage != nil { + if let powerCh3Voltage = metric.powerCh3Voltage { PowerMetricCompactWidget( type: .voltage, - value: metric.powerCh3Voltage, + value: powerCh3Voltage, title: "Channel 3 Voltage" ) } - if metric.powerCh3Current != nil { + if let powerCh3Current = metric.powerCh3Current { PowerMetricCompactWidget( type: .current, - value: metric.powerCh3Current, + value: powerCh3Current, title: "Channel 3 Current" ) } diff --git a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift index e4867544..b4e5336c 100644 --- a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift +++ b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift @@ -116,24 +116,27 @@ struct WeatherConditionsCompactWidget: View { struct HumidityCompactWidget: View { let humidity: Int - let dewPoint: String + let dewPoint: String? var body: some View { VStack(alignment: .leading) { HStack(spacing: 5.0) { Image(systemName: "humidity") .foregroundColor(.accentColor) .font(.callout) - Text("HUMIDITY") + Text("Humidity") + .textCase(.uppercase) .font(.caption) } Text("\(humidity)%") .font(.largeTitle) .padding(.bottom, 5) - Text("The dew point is \(dewPoint) right now.") - .lineLimit(3) - .allowsTightening(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/) - .fixedSize(horizontal: false, vertical: true) - .font(.caption2) + if let dewPoint { + Text("The dew point is \(dewPoint) right now.") + .lineLimit(3) + .allowsTightening(true) + .fixedSize(horizontal: false, vertical: true) + .font(.caption2) + } } .frame(minWidth: 100, idealWidth: 125, maxWidth: 150, minHeight: 120, idealHeight: 130, maxHeight: 140) .padding() @@ -151,7 +154,8 @@ struct PressureCompactWidget: View { Image(systemName: "gauge") .foregroundColor(.accentColor) .font(.callout) - Text("PRESSURE") + Text("Pressure") + .textCase(.uppercase) .font(.caption) } Text(pressure) @@ -168,17 +172,21 @@ struct PressureCompactWidget: View { struct WindCompactWidget: View { let speed: String - let gust: String - let direction: String + let gust: String? + let direction: String? + var body: some View { + let hasGust = ((gust ?? "").isEmpty == false) VStack(alignment: .leading) { - Label { Text("WIND") } icon: { Image(systemName: "wind").foregroundColor(.accentColor) } - Text("\(direction)") - .font(gust.isEmpty ? .callout : .caption) - .padding(.bottom, 10) + Label { Text("Wind").textCase(.uppercase) } icon: { Image(systemName: "wind").foregroundColor(.accentColor) } + if let direction { + Text("\(direction)") + .font(!hasGust ? .callout : .caption) + .padding(.bottom, 10) + } Text(speed) - .font(gust.isEmpty ? .system(size: 45) : .system(size: 35)) - if !gust.isEmpty { + .font(!hasGust ? .system(size: 45) : .system(size: 35)) + if let gust, !gust.isEmpty { Text("Gusts \(gust)") } } diff --git a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift index 958fefa4..9132d068 100644 --- a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift +++ b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift @@ -38,26 +38,30 @@ struct DeviceMetricsLog: View { GroupBox(label: Label("\(deviceMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) { Chart { ForEach(chartData, id: \.self) { point in - Plot { - LineMark( - x: .value("x", point.time!), - y: .value("y", point.batteryLevel) - ) + if let batteryLevel = point.batteryLevel { + Plot { + LineMark( + x: .value("x", point.time!), + y: .value("y", batteryLevel) + ) + } + .accessibilityLabel("Line Series") + .accessibilityValue("X: \(point.time!), Y: \(batteryLevel)") + .foregroundStyle(batteryChartColor) + .interpolationMethod(.linear) } - .accessibilityLabel("Line Series") - .accessibilityValue("X: \(point.time!), Y: \(point.batteryLevel)") - .foregroundStyle(batteryChartColor) - .interpolationMethod(.linear) - Plot { - PointMark( - x: .value("x", point.time!), - y: .value("y", point.channelUtilization) - ) - .symbolSize(25) + if let channelUtilization = point.channelUtilization { + Plot { + PointMark( + x: .value("x", point.time!), + y: .value("y", channelUtilization) + ) + .symbolSize(25) + } + .accessibilityLabel("Line Series") + .accessibilityValue("X: \(point.time!), Y: \(channelUtilization)") + .foregroundStyle(channelUtilizationChartColor) } - .accessibilityLabel("Line Series") - .accessibilityValue("X: \(point.time!), Y: \(point.channelUtilization)") - .foregroundStyle(channelUtilizationChartColor) if let chartSelection { RuleMark(x: .value("Second", chartSelection, unit: .second)) .foregroundStyle(.tertiary.opacity(0.5)) @@ -81,16 +85,18 @@ struct DeviceMetricsLog: View { RuleMark(y: .value("Network Status Red", 50)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 10])) .foregroundStyle(.red) - Plot { - PointMark( - x: .value("x", point.time!), - y: .value("y", point.airUtilTx) - ) - .symbolSize(25) + if let airUtilTx = point.airUtilTx { + Plot { + PointMark( + x: .value("x", point.time!), + y: .value("y", airUtilTx) + ) + .symbolSize(25) + } + .accessibilityLabel("Line Series") + .accessibilityValue("X: \(point.time!), Y: \(airUtilTx)") + .foregroundStyle(airtimeChartColor) } - .accessibilityLabel("Line Series") - .accessibilityValue("X: \(point.time!), Y: \(point.airUtilTx)") - .foregroundStyle(airtimeChartColor) } } .chartXAxis(content: { @@ -122,14 +128,21 @@ struct DeviceMetricsLog: View { Image(systemName: "bolt") .font(.caption) .symbolRenderingMode(.multicolor) - Text("Volts \(String(format: "%.2f", dm.voltage)) ") + Text("Volts \(dm.voltage?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)") .font(.caption2) BatteryCompact(batteryLevel: dm.batteryLevel, font: .caption, iconFont: .callout, color: .accentColor) } HStack { - Text("Channel Utilization \(String(format: "%.2f", dm.channelUtilization))% ") - .foregroundColor(dm.channelUtilization < 25 ? .green : (dm.channelUtilization > 50 ? .red : .orange)) - Text("Airtime \(String(format: "%.2f", dm.airUtilTx))%") + if let channelUtilization = dm.channelUtilization { + // Text("Channel Utilization \(String(format: "%.2f%%", channelUtilization))") + Text("Channel Utilization \(channelUtilization.formatted(.number.precision(.fractionLength(2))))%") + .foregroundColor(channelUtilization < 25 ? .green : (channelUtilization > 50 ? .red : .orange)) + } else { + Text("Channel Utilization " + Constants.nilValueIndicator) + .foregroundColor(.gray) + } + // Keep "Airtime" separate here as to avoid creating a new localization key + Text("Airtime") + Text(" \(dm.airUtilTx?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .foregroundColor(.secondary) Spacer() } @@ -141,27 +154,37 @@ struct DeviceMetricsLog: View { /// Multi Column table for ipads and mac Table(deviceMetrics, selection: $selection, sortOrder: $sortOrder) { TableColumn("Battery Level") { dm in - if dm.batteryLevel > 100 { + if dm.batteryLevel ?? 0 > 100 { Text("Powered") } else { - Text("\(String(dm.batteryLevel))%") + // dm.batteryLevel.map { Text("\(String($0))%") } ?? Text("--") + Text("\(dm.batteryLevel?.formatted(.number.precision(.fractionLength(0))) ?? Constants.nilValueIndicator)%") } } - TableColumn("voltage") { dm in - Text("\(String(format: "%.2f", dm.voltage))") + TableColumn("Voltage") { dm in + // dm.voltage.map { Text("\(String(format: "%.2f", $0))") } ?? Text("--") + Text("\(dm.voltage?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)") } TableColumn("channel.utilization") { dm in - Text("\(String(format: "%.2f", dm.channelUtilization))%") - .foregroundColor(dm.channelUtilization < 25 ? .green : (dm.channelUtilization > 50 ? .red : .orange)) + dm.channelUtilization.map { channelUtilization in + // Text("\(String(format: "%.2f", channelUtilization))%") + Text("\(channelUtilization.formatted(.number.precision(.fractionLength(2))))%") + .foregroundColor(channelUtilization < 25 ? .green : (channelUtilization > 50 ? .red : .orange)) + } ?? Text(Constants.nilValueIndicator) } TableColumn("Airtime") { dm in - Text("\(String(format: "%.2f", dm.airUtilTx))%") + // dm.airUtilTx.map { Text("\(String(format: "%.2f", $0))%") } ?? Text("--") + Text("\(dm.airUtilTx?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)") } TableColumn("Uptime") { dm in - let now = Date.now - let later = now + TimeInterval(dm.uptimeSeconds) - let components = (now.. 0 { + if let dm = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).lastObject as? TelemetryEntity, let uptimeSeconds = dm.uptimeSeconds { HStack { Label { Text("\("uptime".localized)") @@ -156,7 +156,7 @@ struct NodeDetail: View { Spacer() let now = Date.now - let later = now + TimeInterval(dm.uptimeSeconds) + let later = now + TimeInterval(uptimeSeconds) let uptime = (now.. 0.0 { - HumidityCompactWidget(humidity: Int(node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0), dewPoint: String(format: "%.0f", calculateDewPoint(temp: node.latestEnvironmentMetrics?.temperature ?? 0.0, relativeHumidity: node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0)) + "°") + if let temperature = node.latestEnvironmentMetrics?.temperature?.shortFormattedTemperature() { + WeatherConditionsCompactWidget(temperature: String(temperature), symbolName: "cloud.sun", description: "TEMP") } - if node.latestEnvironmentMetrics?.barometricPressure ?? 0.0 > 0.0 { - PressureCompactWidget(pressure: String(format: "%.2f", node.latestEnvironmentMetrics?.barometricPressure ?? 0.0), unit: "hPA", low: node.latestEnvironmentMetrics?.barometricPressure ?? 0.0 <= 1009.144) + if let humidity = node.latestEnvironmentMetrics?.relativeHumidity { + if let temperature = node.latestEnvironmentMetrics?.temperature { + let dewPoint = calculateDewPoint(temp: temperature, relativeHumidity: humidity) + .formatted(.number.precision(.fractionLength(0))) + "°" + HumidityCompactWidget(humidity: Int(humidity), dewPoint: dewPoint) + } else { + HumidityCompactWidget(humidity: Int(humidity), dewPoint: nil) + } } - if node.latestEnvironmentMetrics?.windSpeed ?? 0.0 > 0.0 { - let windSpeed = Measurement(value: Double(node.latestEnvironmentMetrics?.windSpeed ?? 0.0), unit: UnitSpeed.metersPerSecond) - let windGust = Measurement(value: Double(node.latestEnvironmentMetrics?.windGust ?? 0.0), unit: UnitSpeed.metersPerSecond) + if let pressure = node.latestEnvironmentMetrics?.barometricPressure { + PressureCompactWidget(pressure: pressure.formatted(.number.precision(.fractionLength(2))), unit: "hPA", low: pressure <= 1009.144) + } + if let windSpeed = node.latestEnvironmentMetrics?.windSpeed { + let windSpeedMeasurement = Measurement(value: Double(windSpeed), unit: UnitSpeed.metersPerSecond) + let windGust = node.latestEnvironmentMetrics?.windGust.map { Measurement(value: Double($0), unit: UnitSpeed.metersPerSecond) } let direction = cardinalValue(from: Double(node.latestEnvironmentMetrics?.windDirection ?? 0)) - WindCompactWidget(speed: windSpeed.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))), - gust: node.latestEnvironmentMetrics?.windGust ?? 0.0 > 0.0 ? windGust.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))) : "", direction: direction) + WindCompactWidget(speed: windSpeedMeasurement.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))), + gust: node.latestEnvironmentMetrics?.windGust ?? 0.0 > 0.0 ? windGust?.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))) : "", direction: direction) } } .padding(node.latestEnvironmentMetrics?.iaq ?? -1 > 0 ? .bottom : .vertical) diff --git a/Meshtastic/Views/Nodes/PowerMetricsLog.swift b/Meshtastic/Views/Nodes/PowerMetricsLog.swift index 32faabf2..3fd5582a 100644 --- a/Meshtastic/Views/Nodes/PowerMetricsLog.swift +++ b/Meshtastic/Views/Nodes/PowerMetricsLog.swift @@ -39,7 +39,8 @@ struct PowerMetricsLog: View { $0.powerCh2Current, $0.powerCh3Voltage, $0.powerCh3Current - ]} + ].compactMap({$0}) // Remove nils + } guard !allValues.isEmpty else { return (min: -10, max: 10) @@ -72,24 +73,27 @@ struct PowerMetricsLog: View { let voltage = channelSelection == 0 ? point.powerCh1Voltage : channelSelection == 1 ? point.powerCh2Voltage : point.powerCh3Voltage let current = channelSelection == 0 ? point.powerCh1Current : channelSelection == 1 ? point.powerCh2Current : point.powerCh3Current - LineMark( - x: .value("Time", point.time ?? Date()), - y: .value("Voltage", voltage) - ) - .foregroundStyle(by: .value("Series", "Voltage")) - .interpolationMethod(.linear) - .accessibilityLabel("Voltage") - .accessibilityValue("X: \(point.time ?? Date()), Y: \(voltage)") - - LineMark( - x: .value("Time", point.time ?? Date()), - y: .value("Current", current) - ) - .foregroundStyle(by: .value("Series", "Current")) - .interpolationMethod(.linear) - .accessibilityLabel("Current") - .accessibilityValue("X: \(point.time ?? Date()), Y: \(current)") + if let voltage { + LineMark( + x: .value("Time", point.time ?? Date()), + y: .value("Voltage", voltage) + ) + .foregroundStyle(by: .value("Series", "Voltage")) + .interpolationMethod(.linear) + .accessibilityLabel("Voltage") + .accessibilityValue("X: \(point.time ?? Date()), Y: \(voltage)") + } + if let current { + LineMark( + x: .value("Time", point.time ?? Date()), + y: .value("Current", current) + ) + .foregroundStyle(by: .value("Series", "Current")) + .interpolationMethod(.linear) + .accessibilityLabel("Current") + .accessibilityValue("X: \(point.time ?? Date()), Y: \(current)") + } } if let chartSelection { @@ -127,13 +131,13 @@ struct PowerMetricsLog: View { Image(systemName: "powerplug.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh1Voltage))V") + m.powerCh1Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } HStack { Image(systemName: "bolt.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh1Current))mA") + m.powerCh1Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } } } @@ -145,13 +149,13 @@ struct PowerMetricsLog: View { Image(systemName: "powerplug.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh2Voltage))V") + m.powerCh2Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } HStack { Image(systemName: "bolt.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh2Current))mA") + m.powerCh2Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } } } @@ -163,13 +167,13 @@ struct PowerMetricsLog: View { Image(systemName: "powerplug.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh3Voltage))V") + m.powerCh3Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } HStack { Image(systemName: "bolt.fill") .font(.caption) .symbolRenderingMode(.multicolor) - Text("\(String(format: "%.2f", m.powerCh3Current))mA") + m.powerCh3Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } } } @@ -185,27 +189,27 @@ struct PowerMetricsLog: View { } else { Table(powerMetrics, selection: $selection, sortOrder: $sortOrder) { TableColumn("Ch1 Voltage") { dm in - Text("\(String(format: "%.2f", dm.powerCh1Voltage))V") + dm.powerCh1Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Ch1 Current") { dm in - Text("\(String(format: "%.2f", dm.powerCh1Current))mA") + dm.powerCh1Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Ch2 Voltage") { dm in - Text("\(String(format: "%.2f", dm.powerCh2Voltage))V") + dm.powerCh2Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Ch2 Current") { dm in - Text("\(String(format: "%.2f", dm.powerCh2Current))mA") + dm.powerCh2Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Ch3 Voltage") { dm in - Text("\(String(format: "%.2f", dm.powerCh3Voltage))V") + dm.powerCh3Voltage.map { Text("\(String(format: "%.2f", $0))V") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Ch3 Current") { dm in - Text("\(String(format: "%.2f", dm.powerCh3Current))mA") + dm.powerCh3Current.map { Text("\(String(format: "%.2f", $0))mA") } ?? Text(Constants.nilValueIndicator) } .width(min: 75) TableColumn("Timestamp") { dm in diff --git a/Widgets/MeshActivityAttributes.swift b/Widgets/MeshActivityAttributes.swift index 37376531..8d7ea9af 100644 --- a/Widgets/MeshActivityAttributes.swift +++ b/Widgets/MeshActivityAttributes.swift @@ -15,9 +15,9 @@ struct MeshActivityAttributes: ActivityAttributes { public typealias MeshActivityStatus = ContentState public struct ContentState: Codable, Hashable { // Dynamic stateful properties about your activity go here! - var uptimeSeconds: UInt32 - var channelUtilization: Float - var airtime: Float + var uptimeSeconds: UInt32? + var channelUtilization: Float? + var airtime: Float? var sentPackets: UInt32 var receivedPackets: UInt32 var badReceivedPackets: UInt32 diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 7c6396a3..231efec2 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -42,11 +42,13 @@ struct WidgetsLiveActivity: Widget { .foregroundStyle(.secondary) .fixedSize() } - Text("\(String(format: "Ch. Util: %.2f", context.state.channelUtilization))%") + // Text("\(context.state.channelUtilization.map { String(format: "Ch. Util: %.2f", $0) } ?? "--")%") + Text("Ch. Util: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .font(.caption2) .foregroundStyle(.secondary) .fixedSize() - Text("\(String(format: "Airtime: %.2f", context.state.airtime))%") + // Text("\(context.state.airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%") + Text("Airtime: \(context.state.airtime?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .font(.caption2) .foregroundStyle(.secondary) .fixedSize() @@ -118,7 +120,7 @@ struct WidgetsLiveActivity: Widget { struct WidgetsLiveActivity_Previews: PreviewProvider { static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G") - static let state = MeshActivityAttributes.ContentState(uptimeSeconds: 600, channelUtilization: 1.2, airtime: 3.5, sentPackets: 12587, receivedPackets: 12555, badReceivedPackets: 800, dupeReceivedPackets: 100 , packetsSentRelay: 250, packetsCanceledRelay: 372, nodesOnline: 99, totalNodes: 100, timerRange: Date.now...Date(timeIntervalSinceNow: 300)) + static let state = MeshActivityAttributes.ContentState(uptimeSeconds: 600, channelUtilization: 1.2, airtime: 3.5, sentPackets: 12587, receivedPackets: 12555, badReceivedPackets: 800, dupeReceivedPackets: 100, packetsSentRelay: 250, packetsCanceledRelay: 372, nodesOnline: 99, totalNodes: 100, timerRange: Date.now...Date(timeIntervalSinceNow: 300)) static var previews: some View { attributes @@ -140,9 +142,9 @@ struct LiveActivityView: View { @Environment(\.isLuminanceReduced) var isLuminanceReduced var nodeName: String - var uptimeSeconds: UInt32 - var channelUtilization: Float - var airtime: Float + var uptimeSeconds: UInt32? + var channelUtilization: Float? + var airtime: Float? var sentPackets: UInt32 var receivedPackets: UInt32 var badReceivedPackets: UInt32 @@ -179,9 +181,9 @@ struct NodeInfoView: View { @Environment(\.isLuminanceReduced) var isLuminanceReduced var nodeName: String - var uptimeSeconds: UInt32 - var channelUtilization: Float - var airtime: Float + var uptimeSeconds: UInt32? + var channelUtilization: Float? + var airtime: Float? var sentPackets: UInt32 var receivedPackets: UInt32 var badReceivedPackets: UInt32 @@ -199,7 +201,8 @@ struct NodeInfoView: View { .font(nodeName.count > 14 ? .callout : .title3) .fontWeight(.semibold) .foregroundStyle(.tint) - Text("\(String(format: "Ch. Util: %.2f", channelUtilization))% \(String(format: "Airtime: %.2f", airtime))%") + // Text("\(channelUtilization.map { String(format: "Ch. Util: %.2f", $0 ) } ?? "--")% \(airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%") + Text("Ch. Util: \(channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .font(.caption) .fontWeight(.medium) .foregroundStyle(.secondary) @@ -211,21 +214,21 @@ struct NodeInfoView: View { .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.8 : 1.0) .fixedSize() - Text("Bad: \(badReceivedPackets) \(String(format: "Error Rate: %.2f", errorRate))%") + Text("Bad: \(badReceivedPackets) Error Rate: \(errorRate.formatted(.number.precision(.fractionLength(2))))%") .font(.caption) .fontWeight(.medium) .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.8 : 1.0) .fixedSize() if totalNodes >= 100 { - Text("\(String(format: "Connected: %d nodes online", nodesOnline))") + Text("Connected: \(nodesOnline) nodes online") .font(.caption) .fontWeight(.medium) .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.8 : 1.0) .fixedSize() } else { - Text("\(String(format: "Connected: %d of %d nodes online", nodesOnline, totalNodes))") + Text("Connected: \(nodesOnline) of \(totalNodes) nodes online") .font(.caption) .fontWeight(.medium) .foregroundStyle(.secondary) From 02fc125c339fb6aac3fc92ea001de33ace5fe721 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Sat, 22 Feb 2025 08:11:16 -0500 Subject: [PATCH 4/8] Revert "Merge pull request #1107 from RCGV1/fixReadIndicator" This reverts commit 83d261c2769cbba28d02fb9cd7bdc6b7c1190f95, reversing changes made to 6e6de4da64facb30120e39b17191243407876ebe. --- .../Views/Messages/ChannelMessageList.swift | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 276f6fbf..573a7dc8 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -112,8 +112,16 @@ struct ChannelMessageList: View { .frame(maxWidth: .infinity) .id(message.messageId) .onAppear { - Task { - await markMessageAsRead(message) + if !message.read { + message.read = true + do { + try context.save() + Logger.data.info("📖 [App] Read message \(message.messageId) ") + appState.unreadChannelMessages = myInfo.unreadMessages + context.refresh(myInfo, mergeChanges: true) + } catch { + Logger.data.error("Failed to read message \(message.messageId): \(error.localizedDescription)") + } } } } @@ -170,21 +178,4 @@ struct ChannelMessageList: View { } } } - - @MainActor - func markMessageAsRead(_ message: MessageEntity) async { - guard !message.read else { return } - - message.read = true - - do { - try await Task.sleep(nanoseconds: 300_000_000) // 300ms debounce - try context.save() - Logger.data.info("📖 [App] Read message \(message.messageId)") - appState.unreadChannelMessages = myInfo.unreadMessages - context.refresh(myInfo, mergeChanges: true) - } catch { - Logger.data.error("Failed to read message \(message.messageId): \(error.localizedDescription)") - } - } } From b9e99fbe0e5252a8bf5293ffce14919a053b6f98 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 25 Feb 2025 07:02:10 -0800 Subject: [PATCH 5/8] Add Core portnums, reduce MQTT password size --- Localizable.xcstrings | 1 + Meshtastic/Enums/DeviceEnums.swift | 7 +++++++ Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift | 3 +-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index e061cceb..d1ce81ed 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -9171,6 +9171,7 @@ } }, "Empty" : { + "extractionState" : "stale", "localizations" : { "sr" : { "stringUnit" : { diff --git a/Meshtastic/Enums/DeviceEnums.swift b/Meshtastic/Enums/DeviceEnums.swift index 3f76054f..4e0bdf27 100644 --- a/Meshtastic/Enums/DeviceEnums.swift +++ b/Meshtastic/Enums/DeviceEnums.swift @@ -144,6 +144,7 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable { case allSkipDecoding = 1 case localOnly = 2 case knownOnly = 3 + case corePortnums = 4 var id: Int { self.rawValue } @@ -157,6 +158,8 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable { return "Local Only" case .knownOnly: return "Known Only" + case .corePortnums: + return "Core Portnums Only" } } var description: String { @@ -169,6 +172,8 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable { return "Ignores observed messages from foreign meshes that are open or those which it cannot decrypt. Only rebroadcasts message on the nodes local primary / secondary channels." case .knownOnly: return "Ignores observed messages from foreign meshes like Local Only, but takes it step further by also ignoring messages from nodes not already in the node's known list." + case .corePortnums: + return "Only rebroadcasts packets from the core portnums: NodeInfo, Text, Position, Telemetry, and Routing." } } func protoEnumValue() -> Config.DeviceConfig.RebroadcastMode { @@ -182,6 +187,8 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable { return Config.DeviceConfig.RebroadcastMode.localOnly case .knownOnly: return Config.DeviceConfig.RebroadcastMode.knownOnly + case .corePortnums: + return Config.DeviceConfig.RebroadcastMode.corePortnumsOnly } } } diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index d840d1e1..5114a7e4 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -196,7 +196,6 @@ struct MQTTConfig: View { } .keyboardType(.default) .scrollDismissesKeyboard(.interactively) - HStack { Label("password", systemImage: "wallet.pass") TextField("password", text: $password) @@ -206,7 +205,7 @@ struct MQTTConfig: View { .onChange(of: password) { var totalBytes = password.utf8.count // Only mess with the value if it is too big - while totalBytes > 62 { + while totalBytes > 30 { password = String(password.dropLast()) totalBytes = password.utf8.count } From 2eea3057ec0940d8686260ac979ff2b5b0bef346 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 25 Feb 2025 07:36:16 -0800 Subject: [PATCH 6/8] Bump version --- Meshtastic.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index b395a2ad..89b4c893 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -1808,7 +1808,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.19; + MARKETING_VERSION = 2.5.20; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1842,7 +1842,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.19; + MARKETING_VERSION = 2.5.20; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1874,7 +1874,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.19; + MARKETING_VERSION = 2.5.20; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1907,7 +1907,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.19; + MARKETING_VERSION = 2.5.20; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From c6c1ae75d7b08b51b7a00c174058bac95cac7410 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Thu, 27 Feb 2025 09:40:34 -0800 Subject: [PATCH 7/8] Surface ble errors for didFailToConnect method --- Localizable.xcstrings | 3 +-- Meshtastic/Helpers/BLEManager.swift | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 2889fb73..1dfe6d28 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -9167,7 +9167,6 @@ } }, "Empty" : { - "extractionState" : "stale", "localizations" : { "sr" : { "stringUnit" : { @@ -32763,4 +32762,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 13bb65e8..10b48ffd 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -225,8 +225,19 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate // Called when a Peripheral fails to connect func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - cancelPeripheralConnection() - Logger.services.error("🚫 [BLE] Failed to Connect: \(peripheral.name ?? "Unknown", privacy: .public)") + if let e = error { + // https://developer.apple.com/documentation/corebluetooth/cberror/code + let errorCode = (e as NSError).code + cancelPeripheralConnection() + if errorCode == 14 { // Peer removed pairing information + // Forgetting and reconnecting seems to be necessary so we need to show the user an error telling them to do that + lastConnectionError = "🚨 " + String.localizedStringWithFormat("%@ This error usually cannot be fixed without forgetting the device unders Settings > Bluetooth and re pairing the radio.".localized, e.localizedDescription) + Logger.services.error("🚨 [BLE] Failed to connect: \(peripheral.name ?? "Unknown".localized) Error Code: \(errorCode, privacy: .public) Error: \(self.lastConnectionError, privacy: .public)") + } else { + lastConnectionError = "🚨 \(e.localizedDescription)" + Logger.services.error("🚨 [BLE] Failed to connect: \(peripheral.name ?? "Unknown".localized, privacy: .public) Error Code: \(errorCode, privacy: .public) Error: \(e.localizedDescription, privacy: .public)") + } + } } // Disconnect Peripheral Event From 7fec53f5d079f326c3a7aedfb3223bbf1ea914fb Mon Sep 17 00:00:00 2001 From: Jake-B Date: Fri, 28 Feb 2025 08:16:56 -0500 Subject: [PATCH 8/8] Filter variation selectors from shortName field --- Meshtastic/Extensions/String.swift | 11 +++++++++++ Meshtastic/Views/Settings/UserConfig.swift | 9 +++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Meshtastic/Extensions/String.swift b/Meshtastic/Extensions/String.swift index b97ad1c5..c7c6385b 100644 --- a/Meshtastic/Extensions/String.swift +++ b/Meshtastic/Extensions/String.swift @@ -90,4 +90,15 @@ extension String { let end = index(start, offsetBy: range.upperBound - range.lowerBound) return String(self[start ..< end]) } + + // Filter out variation selectors from the string + var withoutVariationSelectors: String { + return self.unicodeScalars + .filter { scalar in + return !scalar.properties.isVariationSelector + } + .compactMap { UnicodeScalar($0) } + .map { String($0) } + .joined() + } } diff --git a/Meshtastic/Views/Settings/UserConfig.swift b/Meshtastic/Views/Settings/UserConfig.swift index 5b388ca7..ea64e36e 100644 --- a/Meshtastic/Views/Settings/UserConfig.swift +++ b/Meshtastic/Views/Settings/UserConfig.swift @@ -75,11 +75,16 @@ struct UserConfig: View { TextField("Short Name", text: $shortName) .foregroundColor(.gray) .onChange(of: shortName) { - var totalBytes = shortName.utf8.count + let newValue = shortName.withoutVariationSelectors + let totalBytes = newValue.utf8.count // Only mess with the value if it is too big if totalBytes > 4 { + // If too long, drop the last thing entered shortName = String(shortName.dropLast()) - totalBytes = shortName.utf8.count + } else if shortName != newValue { + // If not too long, make sure the stripped + // variant is placed back in text field if necessary + shortName = newValue } } .foregroundColor(.gray)