Merge branch '2.5.20' into patch-3

This commit is contained in:
Garth Vander Houwen 2025-02-28 18:21:15 -08:00 committed by GitHub
commit 186670d475
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1107 additions and 495 deletions

View file

@ -2,7 +2,7 @@
"sourceLanguage" : "en",
"strings" : {
"" : {
"shouldTranslate" : false
},
"\t%@" : {
"localizations" : {
@ -36,6 +36,12 @@
}
}
},
" %@%%" : {
},
"--" : {
"shouldTranslate" : false
},
": %@" : {
"localizations" : {
"sr" : {
@ -67,6 +73,9 @@
}
}
}
},
"?" : {
},
"(Re)define PIN_GPS_EN for your board." : {
"localizations" : {
@ -302,8 +311,8 @@
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "%1$@ може имати до %2$@ бајтова."
"state" : "translated",
"value" : "%@ може имати до %@ бајтова."
}
},
"zh-Hans" : {
@ -462,7 +471,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "%@ Апликација ће се аутоматски поново повезати са жељеним радиом ако се врати у домет."
}
},
@ -521,8 +530,8 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "%@ Ова грешка обично не може да се поправи без заборављања уређаја испод подешавања > Блутут и поново повезивање са радиом."
"state" : "translated",
"value" : "%@ Ова грешка обично не може да се поправи без заборављања уређаја под Подешавања > Блутут и поново повезивање са радиом."
}
},
"zh-Hans" : {
@ -616,10 +625,24 @@
}
},
"%@mA" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@mA"
}
}
}
},
"%@V" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@V"
}
}
}
},
"%d" : {
"localizations" : {
@ -728,6 +751,9 @@
}
}
}
},
"%f%%" : {
},
"%lf" : {
"localizations" : {
@ -914,7 +940,7 @@
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "2.4 GHz"
}
}
@ -1059,10 +1085,24 @@
}
},
"About" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Детаљи"
}
}
}
},
"About Meshtastic" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "О Мештастику"
}
}
}
},
"Accuracy %@" : {
"localizations" : {
@ -1376,7 +1416,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "пре"
}
},
@ -1434,7 +1474,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Време емитовања"
}
},
@ -1452,22 +1492,6 @@
}
}
},
"Airtime %@%%" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Време емитовања %@%%"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "广播时间 %@%%"
}
}
}
},
"Alert" : {
"localizations" : {
"sr" : {
@ -1723,7 +1747,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Увек укључен"
}
},
@ -1797,7 +1821,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Амбијентално осветљење"
}
},
@ -1855,7 +1879,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Подешавања амбијенталног осветљења"
}
},
@ -2069,7 +2093,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Да ли си сигуран?"
}
},
@ -2092,7 +2116,7 @@
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Аустралија / Нови Зеланд"
}
}
@ -2176,7 +2200,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Доступни радио уређаји"
}
},
@ -2320,7 +2344,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Ниво батерије"
}
},
@ -2410,7 +2434,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "BLE назив"
}
},
@ -2468,7 +2492,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "BLE пин мора имати 6 цифара."
}
},
@ -2616,7 +2640,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Блутут подешавања"
}
},
@ -2674,7 +2698,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Блутут је искључен"
}
},
@ -2797,7 +2821,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Бајтова"
}
},
@ -2887,7 +2911,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Откажи"
}
},
@ -2945,7 +2969,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Унапред припремљене поруке"
}
},
@ -3003,7 +3027,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Подешавања унапред припремљених порука"
}
},
@ -3249,22 +3273,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" : {
@ -3306,7 +3372,7 @@
},
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Канал"
}
},
@ -3341,7 +3407,14 @@
}
},
"Channel 1" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Канал 1"
}
}
}
},
"Channel 1 Included" : {
"localizations" : {
@ -3360,7 +3433,14 @@
}
},
"Channel 2" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Канал 2"
}
}
}
},
"Channel 2 Included" : {
"localizations" : {
@ -3379,7 +3459,14 @@
}
},
"Channel 3" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Канал 3"
}
}
}
},
"Channel 3 Included" : {
"localizations" : {
@ -3526,9 +3613,16 @@
}
},
"Channel URL" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "URL канала"
}
}
}
},
"Channel Utilization %@%% " : {
"Channel Utilization %@%%" : {
"localizations" : {
"sr" : {
"stringUnit" : {
@ -3914,7 +4008,7 @@
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "Кина"
}
}
@ -3937,7 +4031,14 @@
}
},
"Clear Log" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Очисти логове"
}
}
}
},
"clear.app.data" : {
"localizations" : {
@ -4186,7 +4287,14 @@
}
},
"Communicating" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Комуницирање"
}
}
}
},
"Config" : {
"localizations" : {
@ -6215,7 +6323,14 @@
}
},
"Current" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Струја"
}
}
}
},
"Current Firmware Version: %@" : {
"localizations" : {
@ -6614,7 +6729,14 @@
}
},
"Delete Power metrics?" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Обрисати метрику снаге?"
}
}
}
},
"Description" : {
"localizations" : {
@ -7775,6 +7897,12 @@
"state" : "translated",
"value" : "Router Late"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Рутер са кашњењем"
}
}
}
},
@ -8103,6 +8231,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" : "Инфраструктурни чвор који увек поново емитује пакете једном, али тек након свих осталих модова, обезбеђујући додатну покривеност за локалне кластере. Видљив у листи чворова."
}
}
}
},
@ -12056,22 +12190,6 @@
}
}
},
"HUMIDITY" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "LUFTFEUCHTIGKEIT"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "ВЛАЖНОСТ"
}
}
}
},
"hybrid" : {
"extractionState" : "migrated",
"localizations" : {
@ -20648,7 +20766,14 @@
}
},
"Navigate to node" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Пређите на чвор"
}
}
}
},
"Nearby Topics" : {
"localizations" : {
@ -21086,7 +21211,14 @@
}
},
"No Power Metrics" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Нема метрике снаге"
}
}
}
},
"no.nodes" : {
"extractionState" : "manual",
@ -23191,7 +23323,14 @@
}
},
"Power" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Снага"
}
}
}
},
"Power Metrics" : {
"localizations" : {
@ -23204,10 +23343,24 @@
}
},
"Power Metrics Log" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Дневник метрика снаге"
}
}
}
},
"Power Metrics Log}" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Дневник метрика снаге}"
}
}
}
},
"Power Off" : {
"localizations" : {
@ -23247,6 +23400,12 @@
"state" : "translated",
"value" : "Delete all power metrics?"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Обрисати све метрике снаге?"
}
}
}
},
@ -23258,6 +23417,12 @@
"state" : "translated",
"value" : "Power Metrics Log"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Дневник метрика снаге"
}
}
}
},
@ -23390,18 +23555,18 @@
}
}
},
"PRESSURE" : {
"Pressure" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "DRUCK"
"value" : "Druck"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "ПРИТИСАК"
"value" : "Притисак"
}
}
}
@ -24968,7 +25133,14 @@
}
},
"Route Recorder" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Снимач рута"
}
}
}
},
"Route recording paused" : {
"localizations" : {
@ -25017,7 +25189,14 @@
}
},
"Routes" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Руте"
}
}
}
},
"routes.activitytype.biking" : {
"extractionState" : "migrated",
@ -26575,7 +26754,14 @@
}
},
"Save Channel Settings" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Сачувај подешавања канала"
}
}
}
},
"Save User Config to %@?" : {
"localizations" : {
@ -26861,7 +27047,14 @@
}
},
"Select Channel" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Одабери канал"
}
}
}
},
"select.contact" : {
"extractionState" : "manual",
@ -27025,10 +27218,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" : {
@ -27057,7 +27264,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" : {
@ -28763,6 +28977,12 @@
},
"Store & Forward" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Чување и прослеђивање"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@ -28773,6 +28993,12 @@
},
"Store & Forward Config" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Подешавања чувања и прослеђивања"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@ -28872,6 +29098,12 @@
"state" : "translated",
"value" : "מחובר למש"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Претплаћен"
}
}
}
},
@ -28935,7 +29167,14 @@
}
},
"Takes a Meshtastic channel URL and saves the channel settings." : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Преузима URL Meshtastic канала и чува подешавања канала"
}
}
}
},
"Tapback" : {
"localizations" : {
@ -30139,7 +30378,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" : {
@ -30394,7 +30640,14 @@
}
},
"timestamp" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "временска ознака"
}
}
}
},
"Timestamp" : {
"localizations" : {
@ -31472,7 +31725,14 @@
}
},
"unknown" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "непознато"
}
}
}
},
"Unknown" : {
"localizations" : {
@ -31907,7 +32167,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" : {
@ -31979,6 +32246,10 @@
"stringUnit" : {
"state" : "translated",
"value" : "Wird verwendet, um einen gemeinsamen Schlüssel mit einem Remote-Gerät zu erstellen."
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Користи се за креирање заједничког кључа са удаљеним уређајем"
}
}
}
@ -32352,7 +32623,7 @@
}
}
},
"voltage" : {
"Voltage" : {
"localizations" : {
"de" : {
"stringUnit" : {
@ -32417,7 +32688,14 @@
}
},
"Voltage" : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Напон"
}
}
}
},
"Volts %@ " : {
"localizations" : {
@ -32660,7 +32938,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" : {
@ -32678,18 +32963,12 @@
}
}
},
"WIND" : {
"Wind" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "WIND"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "ВЕТАР"
"value" : "Ветар"
}
}
}
@ -32889,10 +33168,24 @@
}
},
"Your MQTT Server must support TLS." : {
"localizations" : {
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ваш MQTT сервер мора подржавати TLS"
}
}
}
},
"Your nodes 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" : {

View file

@ -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 = "<group>"; };
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = "<group>"; };
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = "<group>"; };
2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedAttributePropertyWrapper.swift; sourceTree = "<group>"; };
2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataClass.swift"; sourceTree = "<group>"; };
2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataProperties.swift"; sourceTree = "<group>"; };
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = "<group>"; };
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = "<group>"; };
2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnviornmentDefaultSeries.swift; sourceTree = "<group>"; };
@ -603,6 +610,15 @@
path = "Metrics Columns";
sourceTree = "<group>";
};
2344A2AC2D66978000170A77 /* CoreData */ = {
isa = PBXGroup;
children = (
2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */,
2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */,
);
path = CoreData;
sourceTree = "<group>";
};
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 */,
@ -1786,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;
@ -1820,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;
@ -1852,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 = "";
@ -1885,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 = "";

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -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 = "--"
}

View file

@ -0,0 +1,61 @@
//
// ManagedAttributePropertyWrapper.swift
// Meshtastic
//
// Created by Jake Bordens on 12/26/24.
//
import CoreData
@propertyWrapper
public struct ManagedAttribute<Value: Numeric> {
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<EnclosingSelf: NSManagedObject>(
_enclosingInstance observed: EnclosingSelf,
wrapped wrappedKeyPath: KeyPath<EnclosingSelf, Value?>,
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, ManagedAttribute<Value>>
) -> 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)
}
}
}
}

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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

View file

@ -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<T>(_ 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),

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24D70" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AmbientLightingConfigEntity" representedClassName="AmbientLightingConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="blue" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
@ -390,7 +390,7 @@
<attribute name="powerUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="telemetryConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetryConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TelemetryEntity" representedClassName="TelemetryEntity" syncable="YES" codeGenerationType="class">
<entity name="TelemetryEntity" representedClassName="TelemetryEntity" syncable="YES">
<attribute name="airUtilTx" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="barometricPressure" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="batteryLevel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>

View file

@ -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<Float>(attributeName: "airUtilTx") public var airUtilTx: Float?
@ManagedAttribute<Float>(attributeName: "barometricPressure") public var barometricPressure: Float?
@ManagedAttribute<Int32>(attributeName: "batteryLevel") public var batteryLevel: Int32?
@ManagedAttribute<Float>(attributeName: "channelUtilization") public var channelUtilization: Float?
@ManagedAttribute<Float>(attributeName: "current") public var current: Float?
@ManagedAttribute<Float>(attributeName: "distance") public var distance: Float?
@ManagedAttribute<Float>(attributeName: "gasResistance") public var gasResistance: Float?
@ManagedAttribute<Int32>(attributeName: "iaq") public var iaq: Int32?
@ManagedAttribute<Float>(attributeName: "powerCh1Current") var powerCh1Current: Float?
@ManagedAttribute<Float>(attributeName: "powerCh1Voltage") var powerCh1Voltage: Float?
@ManagedAttribute<Float>(attributeName: "powerCh2Current") var powerCh2Current: Float?
@ManagedAttribute<Float>(attributeName: "powerCh2Voltage") var powerCh2Voltage: Float?
@ManagedAttribute<Float>(attributeName: "powerCh3Current") var powerCh3Current: Float?
@ManagedAttribute<Float>(attributeName: "powerCh3Voltage") var powerCh3Voltage: Float?
@ManagedAttribute<Float>(attributeName: "relativeHumidity") public var relativeHumidity: Float?
@ManagedAttribute<Int32>(attributeName: "rssi") public var rssi: Int32?
@ManagedAttribute<Float>(attributeName: "snr") public var snr: Float?
@ManagedAttribute<Float>(attributeName: "temperature") public var temperature: Float?
@ManagedAttribute<Int32>(attributeName: "uptimeSeconds") public var uptimeSeconds: Int32?
@ManagedAttribute<Float>(attributeName: "voltage") public var voltage: Float?
@ManagedAttribute<Float>(attributeName: "weight") public var weight: Float?
@ManagedAttribute<Int32>(attributeName: "windDirection") public var windDirection: Int32?
@ManagedAttribute<Float>(attributeName: "windGust") public var windGust: Float?
@ManagedAttribute<Float>(attributeName: "windLull") public var windLull: Float?
@ManagedAttribute<Float>(attributeName: "windSpeed") public var windSpeed: Float?
}

View file

@ -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<TelemetryEntity> {
return NSFetchRequest<TelemetryEntity>(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?
}

View file

@ -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<Value, TableContent: View>(
id: String,
keyPath: KeyPath<TelemetryEntity, Value>,
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)
}
}

View file

@ -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<Value, ChartBody: ChartContent, ForegroundStyle: ShapeStyle>(
id: String,
keyPath: KeyPath<TelemetryEntity, Value>,
name: String,
abbreviatedName: String,
@ -46,10 +48,10 @@ class MetricsChartSeries: ObservableObject {
visible: Bool = true,
foregroundStyle: @escaping ((ClosedRange<Float>?) -> ForegroundStyle?) = { _ in nil },
@ChartContentBuilder chartBody: @escaping (MetricsChartSeries, ClosedRange<Float>?, 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)
}
}

View file

@ -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

View file

@ -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)
}

View file

@ -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"
)
}

View file

@ -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)")
}
}

View file

@ -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..<later).formatted(.components(style: .narrow))
Text(components)
if let uptimeSeconds = dm.uptimeSeconds {
let now = Date.now
let later = now + TimeInterval(uptimeSeconds)
let components = (now..<later).formatted(.components(style: .narrow))
Text(components)
} else {
Text(Constants.nilValueIndicator)
}
}
.width(min: 100)
TableColumn("Timestamp") { dm in

View file

@ -56,25 +56,25 @@ struct EnvironmentMetricsLog: View {
// Add a table for mac and ipad
Table(environmentMetrics) {
TableColumn("Temperature") { em in
columnList.column(forAttribute: "temperature")?.body(em)
columnList.column(withId: "temperature")?.body(em)
}
TableColumn("Humidity") { em in
columnList.column(forAttribute: "relativeHumidity")?.body(em)
columnList.column(withId: "relativeHumidity")?.body(em)
}
TableColumn("Barometric Pressure") { em in
columnList.column(forAttribute: "barometricPressure")?.body(em)
columnList.column(withId: "barometricPressure")?.body(em)
}
TableColumn("Indoor Air Quality") { em in
columnList.column(forAttribute: "iaq")?.body(em)
columnList.column(withId: "iaq")?.body(em)
}
TableColumn("Wind Speed") { em in
columnList.column(forAttribute: "windSpeed")?.body(em)
columnList.column(withId: "windSpeed")?.body(em)
}
TableColumn("Wind Direction") { em in
columnList.column(forAttribute: "windDirection")?.body(em)
columnList.column(withId: "windDirection")?.body(em)
}
TableColumn("Timestamp") { em in
columnList.column(forAttribute: "time")?.body(em)
columnList.column(withId: "time")?.body(em)
}
.width(min: 180)
}

View file

@ -15,10 +15,11 @@ extension MetricsSeriesList {
MetricsSeriesList([
// Temperature Series Configuration
MetricsChartSeries(
id: "temperature",
keyPath: \.temperature,
name: "Temperature",
abbreviatedName: "Temp",
conversion: { Float($0.localeTemperature()) },
conversion: { t in t.map { Float($0.localeTemperature()) } },
foregroundStyle: { chartRange in
let locale = NSLocale.current as NSLocale
let localeUnit = locale.object(forKey: NSLocale.Key(rawValue: "kCFLocaleTemperatureUnitKey"))
@ -29,30 +30,33 @@ extension MetricsSeriesList {
return LinearGradient(stops: stops, startPoint: .bottom, endPoint: .top)
},
chartBody: { series, chartRange, time, temperature in
AreaMark(
x: .value("Time", time),
yStart: .value(series.abbreviatedName, chartRange?.lowerBound.doubleValue ?? 0.0),
yEnd: .value(
series.abbreviatedName, temperature.localeTemperature())
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.alignsMarkStylesWithPlotArea()
.accessibilityHidden(true)
.opacity(0.6)
LineMark(
x: .value("Time", time),
y: .value(
series.abbreviatedName, temperature.localeTemperature())
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
if let temperature {
AreaMark(
x: .value("Time", time),
yStart: .value(series.abbreviatedName, chartRange?.lowerBound.doubleValue ?? 0.0),
yEnd: .value(
series.abbreviatedName, temperature.localeTemperature())
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.alignsMarkStylesWithPlotArea()
.accessibilityHidden(true)
.opacity(0.6)
LineMark(
x: .value("Time", time),
y: .value(
series.abbreviatedName, temperature.localeTemperature())
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
}
}),
// Relative Humidity Series Configuration
MetricsChartSeries(
id: "relativeHumidity",
keyPath: \.relativeHumidity,
name: "Relative Humidity",
abbreviatedName: "Hum",
@ -63,18 +67,21 @@ extension MetricsSeriesList {
)
},
chartBody: { series, _, time, humidity in
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, humidity)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
if let humidity {
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, humidity)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
}
}),
// Barometric Pressure Series Configuration
MetricsChartSeries(
id: "barometricPressure",
keyPath: \.barometricPressure,
name: "Barometric Pressure",
abbreviatedName: "Bar",
@ -86,44 +93,49 @@ extension MetricsSeriesList {
)
},
chartBody: { series, _, time, pressure in
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, pressure)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
if let pressure {
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, pressure)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
}
}),
// Indoor Air Quality Series Configuration
MetricsChartSeries(
id: "iaq",
keyPath: \.iaq,
name: "Indoor Air Quality",
abbreviatedName: "IAQ",
visible: false,
foregroundStyle: { _ in .gray },
chartBody: { series, _, time, iaq in
let iaqEnum = Iaq.getIaq(for: Int(iaq))
PointMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, Float(iaq))
)
.symbol(Circle())
.foregroundStyle(iaqEnum.color)
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, Float(iaq))
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
if let iaq {
let iaqEnum = Iaq.getIaq(for: Int(iaq))
PointMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, Float(iaq))
)
.symbol(Circle())
.foregroundStyle(iaqEnum.color)
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, Float(iaq))
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
}
}),
// Combined Wind Speed and Direction Series Configuration -- For use in Chart only
MetricsChartSeries(
id: "windSpeedAndDirection",
keyPath: \.windSpeedAndDirection,
name: "Wind Speed/Direction",
abbreviatedName: "Speed/Dir",
@ -135,26 +147,30 @@ extension MetricsSeriesList {
)
},
chartBody: { series, _, time, wsad in
// debug data: var wsad = WindSpeedAndDirection(windSpeed:Float.random(in:0...25), windDirection: Int32.random(in:0..<3)*90 )
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, wsad.windSpeed)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
PointMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, wsad.windSpeed)
)
.symbol {
Image(systemName: "location.north.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(Color.white, Color(UIColor.yellow.darker(componentDelta: 0.3)))
.rotationEffect(
.degrees(Double(wsad.windDirection)))
}.foregroundStyle(.yellow)
if let wsad {
// debug data: var wsad = WindSpeedAndDirection(windSpeed:Float.random(in:0...25), windDirection: Int32.random(in:0..<3)*90 )
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, wsad.windSpeed)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
PointMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, wsad.windSpeed)
)
.symbol {
if let wd = wsad.windDirection {
Image(systemName: "location.north.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(Color.white, Color(UIColor.yellow.darker(componentDelta: 0.3)))
.rotationEffect(
.degrees(Double(wd)))
}
}.foregroundStyle(.yellow)
}
})
])
}
@ -165,8 +181,8 @@ extension MetricsSeriesList {
@objc class WindSpeedAndDirection: NSObject, Plottable, Comparable {
let windSpeed: Float
let windDirection: Int32
init(windSpeed: Float, windDirection: Int32) {
let windDirection: Int32?
init(windSpeed: Float, windDirection: Int32?) {
self.windSpeed = windSpeed
self.windDirection = windDirection
}
@ -181,9 +197,10 @@ extension MetricsSeriesList {
}
@objc extension TelemetryEntity {
var windSpeedAndDirection: WindSpeedAndDirection {
return WindSpeedAndDirection(
windSpeed: self.windSpeed, windDirection: self.windDirection)
var windSpeedAndDirection: WindSpeedAndDirection? {
guard let windSpeed = self.windSpeed else { return nil }
return WindSpeedAndDirection(windSpeed: windSpeed, windDirection: self.windDirection)
}
}

View file

@ -15,50 +15,67 @@ extension MetricsColumnList {
MetricsColumnList(columns: [
// Temperature Series Configuration
MetricsTableColumn(
id: "temperature",
keyPath: \.temperature,
name: "Temperature",
abbreviatedName: "Temp",
minWidth: 30, maxWidth: 45,
tableBody: { _, temp in
Text(temp.formattedTemperature())
temp.map {
Text($0.formattedTemperature())
} ?? Text(verbatim: Constants.nilValueIndicator)
}),
// Relative Humidity Series Configuration
MetricsTableColumn(
id: "relativeHumidity",
keyPath: \.relativeHumidity,
name: "Relative Humidity",
abbreviatedName: "Hum",
minWidth: 30, maxWidth: 45,
tableBody: { _, humidity in
Text("\(String(format: "%.0f", humidity))%")
humidity.map {
Text("\($0.formatted(.number.grouping(.never).precision(.fractionLength(0))))%")
} ?? Text(verbatim: Constants.nilValueIndicator)
}),
// Barometric Pressure Series Configuration
MetricsTableColumn(
id: "barometricPressure",
keyPath: \.barometricPressure,
name: "Barometric Pressure",
abbreviatedName: "Bar",
minWidth: 30, maxWidth: 50,
tableBody: { _, pressure in
if (UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) {
Text("\(String(format: "%.1f hPa", pressure))")
} else {
Text("\(String(format: "%.1f", pressure))")
}
pressure.map {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
// Text("\(String(format: "%.1f hPa", $0))")
Text(Measurement(value: Double($0), unit: UnitPressure.hectopascals), format: .measurement(width: .abbreviated, numberFormatStyle: .number.grouping(.never).precision(.fractionLength(1))))
} else {
// Text("\(String(format: "%.1f", $0))")
Text($0, format: .number.grouping(.never).precision(.fractionLength(1)))
}
} ?? Text(verbatim: Constants.nilValueIndicator)
}),
// Indoor Air Quality Series Configuration
MetricsTableColumn(
id: "iaq",
keyPath: \.iaq,
name: "Indoor Air Quality",
abbreviatedName: "IAQ",
minWidth: 30, maxWidth: 50,
tableBody: { _, iaq in
IndoorAirQuality(iaq: Int(iaq), displayMode: .dot)
if let iaq {
IndoorAirQuality(iaq: Int(iaq), displayMode: .dot)
} else {
Text(verbatim: Constants.nilValueIndicator)
}
}),
// Wind Direction Series Configuration
MetricsTableColumn(
id: "windDirection",
keyPath: \.windDirection,
name: "Wind Direction",
abbreviatedName: "Dir",
@ -67,41 +84,52 @@ extension MetricsColumnList {
tableBody: { _, wind in
HStack(spacing: 1.0) {
// debug data: let wind = Double.random(in: 0..<360.0)
let wind = Double(wind)
Image(systemName: "location.north")
.imageScale(.small)
.scaleEffect(0.9, anchor: .center)
.rotationEffect(.degrees(wind))
.foregroundStyle(.blue)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
Text(cardinalValue(from: wind))
if let wind {
HStack(spacing: 1.0) {
// debug data: let wind = Double.random(in: 0..<360.0)
let wind = Double(wind)
Image(systemName: "location.north")
.imageScale(.small)
.scaleEffect(0.9, anchor: .center)
.rotationEffect(.degrees(wind))
.foregroundStyle(.blue)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
Text(cardinalValue(from: wind))
} else {
Text(abbreviatedCardinalValue(from: wind))
}
}
} else {
Text(abbreviatedCardinalValue(from: wind))
Text(verbatim: Constants.nilValueIndicator)
}
}
}),
// Wind Speed Series Configuration
MetricsTableColumn(
id: "windSpeed",
keyPath: \.windSpeed,
name: "Wind Speed",
abbreviatedName: "Wind",
minWidth: 30, maxWidth: 60,
visible: false,
tableBody: { _, speed in
let windSpeed = Measurement(
value: Double(speed), unit: UnitSpeed.kilometersPerHour)
Text(
windSpeed.formatted(
.measurement(
width: .abbreviated,
numberFormatStyle: .number.precision(
.fractionLength(0))))
)
speed.map {
let windSpeed = Measurement(
value: Double($0), unit: UnitSpeed.kilometersPerHour)
return Text(
windSpeed.formatted(
.measurement(
width: .abbreviated,
numberFormatStyle: .number.grouping(.never)
.precision(.fractionLength(0))))
)
} ?? Text(verbatim: Constants.nilValueIndicator)
}),
// Timestamp Series Configuration -- for use in table only
MetricsTableColumn(
id: "time",
keyPath: \.time,
name: "Timestamp",
abbreviatedName: "Time",

View file

@ -144,7 +144,7 @@ struct NodeDetail: View {
}
}
if let dm = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).lastObject as? TelemetryEntity, dm.uptimeSeconds > 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..<later).formatted(.components(style: .narrow))
Text(uptime)
.textSelection(.enabled)
@ -206,7 +206,13 @@ struct NodeDetail: View {
}
}
}
if node.hasPositions && UserDefaults.environmentEnableWeatherKit || node.hasEnvironmentMetrics {
// Note, as you add widgets, you should add to the `hasDataForLatestPositions` array
// This will make sure the "Environment" section is only displayed when the node has a position
// to use with WeatherKit, or has actual data in the most recent EnvironmentMetrics entity
// that will be rendered in this section.
if node.hasPositions && UserDefaults.environmentEnableWeatherKit
|| node.hasDataForLatestEnvironmentMetrics(attributes: ["iaq", "temperature", "relativeHumidity", "barometricPressure", "windSpeed"]) {
Section("Environment") {
if !node.hasEnvironmentMetrics {
LocalWeatherConditions(location: node.latestPosition?.nodeLocation)
@ -217,19 +223,27 @@ struct NodeDetail: View {
.padding(.vertical)
}
LazyVGrid(columns: gridItemLayout) {
WeatherConditionsCompactWidget(temperature: String(node.latestEnvironmentMetrics?.temperature.shortFormattedTemperature() ?? "99°"), symbolName: "cloud.sun", description: "TEMP")
if node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0 > 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)

View file

@ -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

View file

@ -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
}

View file

@ -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)

View file

@ -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

View file

@ -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)