diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 52509a9ad..ff1c794ac 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3793,6 +3793,86 @@ } } } + }, + "%d dBm" : { + + }, + "A local stats request has been sent to %@. Responses can take some time." : { + "comment" : "An alert message explaining that a local stats request has been sent to a specific node. The placeholder is replaced with the node name.", + "isCommentAutoGenerated" : true + }, + "Bad Rx" : { + + }, + "Canceled" : { + + }, + "Canceled: %d" : { + + }, + "Delete all local stats?" : { + + }, + "Dupes" : { + + }, + "Dupes: %d" : { + + }, + "Local Stats" : { + + }, + "Local Stats (in %llds)" : { + + }, + "Local Stats Log" : { + + }, + "Local Stats Requested" : { + "comment" : "The title of an alert that appears when a user successfully requests a local stats update.", + "isCommentAutoGenerated" : true + }, + "No Local Stats" : { + + }, + "No Reading" : { + + }, + "Nodes Online" : { + + }, + "Noise Floor" : { + + }, + "Noise Floor %d dBm" : { + + }, + "Noise Floor No Reading" : { + + }, + "Noise floor is a directional diagnostic. Readings can vary quickly, and external filters can lower or skew the displayed value due to insertion loss or in-band interference." : { + + }, + "Packets Rx" : { + + }, + "Packets Tx" : { + + }, + "Relayed" : { + + }, + "Relayed: %d" : { + + }, + "Request Local Stats" : { + + }, + "Total Nodes" : { + + }, + "Threshold (-85 dBm)" : { + }, "%lld received" : { "comment" : "A label that shows the number of packets that were received.", @@ -101005,4 +101085,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 246cf0847..c534e77ab 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -96,6 +96,7 @@ 251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */; }; 251926872C3BAE2200249DF5 /* NodeAlertsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */; }; 2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */; }; + DD4074692F1233F400BCC22F /* ExchangeUserInfoButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4074682F1233F400BCC22F /* ExchangeUserInfoButton.swift */; }; 2519268C2C3BB52000249DF5 /* TraceRouteButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2519268B2C3BB52000249DF5 /* TraceRouteButton.swift */; }; 251926902C3CB44900249DF5 /* ClientHistoryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2519268F2C3CB44900249DF5 /* ClientHistoryButton.swift */; }; 251926922C3CB52300249DF5 /* DeleteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926912C3CB52300249DF5 /* DeleteNodeButton.swift */; }; @@ -122,6 +123,7 @@ 48E682ECE71246C3BFBD6B8F /* ChannelEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45E6EDE744E3446FA1084499 /* ChannelEntity.swift */; }; 5D3AFC6F08ED4C909C830C55 /* TelemetryEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31483AF5F5354D3481698E32 /* TelemetryEntity.swift */; }; 6467579F9E9E4B0EAA009615 /* MeshtasticSchemaV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05E0BD1A81CF4F6A90D56CE3 /* MeshtasticSchemaV1.swift */; }; + BCCA0BB22F1D0001007648E5 /* MeshtasticSchemaV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCA0BB12F1D0001007648E5 /* MeshtasticSchemaV2.swift */; }; 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F203877F307073096C89179 /* FountainCodec.swift */; }; 6AC10554C1BCC1DF509C85EE /* NodeListHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A96594950B87789C0BB96DB /* NodeListHelp.swift */; }; 6C95257E7B3E55D4FED5694C /* MapWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B990EE65E4D5CABBA4DD8DC2 /* MapWindow.swift */; }; @@ -213,6 +215,9 @@ BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613822C672A2600485544 /* MessageChannelIntent.swift */; }; BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613842C68703800485544 /* NodePositionIntent.swift */; }; BCB613872C69A0FB00485544 /* AppIntentErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613862C69A0FB00485544 /* AppIntentErrors.swift */; }; + BCCA0BAB2F1C5C25007648E5 /* LocalStatsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCA0BAA2F1C5C25007648E5 /* LocalStatsLog.swift */; }; + BCCA0BAD2F1C5C60007648E5 /* RequestLocalStatsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCA0BAC2F1C5C60007648E5 /* RequestLocalStatsButton.swift */; }; + BCCA0BB42F1D0101007648E5 /* UIKeyboardType.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCA0BB32F1D0101007648E5 /* UIKeyboardType.swift */; }; BCD7448D2E0F2FAA00F265A2 /* ContactURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */; }; BCD93CBA2D9E11A2006C9214 /* DisconnectNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */; }; BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */; }; @@ -231,7 +236,6 @@ D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D42B812B700066FBC8 /* MessageDestination.swift */; }; D93068D72B8146690066FBC8 /* MessageText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D62B8146690066FBC8 /* MessageText.swift */; }; D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D82B81509C0066FBC8 /* TapbackResponses.swift */; }; - D93068DA2B81509D0066FBC8 /* TapbackInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D92B81509D0066FBC8 /* TapbackInputView.swift */; }; D93068DB2B81C85E0066FBC8 /* PowerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068DA2B81C85E0066FBC8 /* PowerConfig.swift */; }; D93068DD2B81CA820066FBC8 /* ConfigHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068DC2B81CA820066FBC8 /* ConfigHeader.swift */; }; D93069082B81DF040066FBC8 /* SaveConfigButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93069072B81DF040066FBC8 /* SaveConfigButton.swift */; }; @@ -334,7 +338,6 @@ DD3CC6C028E7A60700FA9159 /* MessagingEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3CC6BF28E7A60700FA9159 /* MessagingEnums.swift */; }; DD3CC6C228EB9D4900FA9159 /* UpdateSwiftData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3CC6C128EB9D4900FA9159 /* UpdateSwiftData.swift */; }; DD3D17E02C3FB67200561584 /* LocalWeatherConditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3D17DF2C3FB67200561584 /* LocalWeatherConditions.swift */; }; - DD4074692F1233F400BCC22F /* ExchangeUserInfoButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4074682F1233F400BCC22F /* ExchangeUserInfoButton.swift */; }; DD41582628582E9B009B0E59 /* DeviceConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD41582528582E9B009B0E59 /* DeviceConfig.swift */; }; DD415828285859C4009B0E59 /* TelemetryConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD415827285859C4009B0E59 /* TelemetryConfig.swift */; }; DD41582A28585C32009B0E59 /* RangeTestConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD41582928585C32009B0E59 /* RangeTestConfig.swift */; }; @@ -380,6 +383,7 @@ DD93800B2BA3F968008BEC06 /* NodeMapContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD93800A2BA3F968008BEC06 /* NodeMapContent.swift */; }; DD93800E2BA74D0C008BEC06 /* ChannelForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD93800D2BA74D0C008BEC06 /* ChannelForm.swift */; }; DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD94B73F2ACCE3BE00DCD1D1 /* MapSettingsForm.swift */; }; + DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FBC296E6B01007C176F /* EmojiOnlyTextField.swift */; }; DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */; }; DD964FC62975DBFD007C176F /* QuerySwiftData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC52975DBFD007C176F /* QuerySwiftData.swift */; }; DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD97E96528EFD9820056DDA4 /* MeshtasticLogo.swift */; }; @@ -394,7 +398,6 @@ DD9C70112E916EBD00106227 /* UpdateIntervalPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9C70102E916EA200106227 /* UpdateIntervalPicker.swift */; }; DDA0B6B2294CDC55001356EC /* Channels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA0B6B1294CDC55001356EC /* Channels.swift */; }; DDA1C48E28DB49D3009933EC /* ChannelRoles.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA1C48D28DB49D3009933EC /* ChannelRoles.swift */; }; - DDA3DFDA2F10B39600D8F103 /* UIKeyboardType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA3DFD92F10B39600D8F103 /* UIKeyboardType.swift */; }; DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */; }; DDA9515A2BC6624100CEA535 /* TelemetryWeather.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA951592BC6624100CEA535 /* TelemetryWeather.swift */; }; DDA9515C2BC6631200CEA535 /* TelemetryEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9515B2BC6631200CEA535 /* TelemetryEnums.swift */; }; @@ -546,6 +549,7 @@ /* Begin PBXFileReference section */ 01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = ""; }; 05E0BD1A81CF4F6A90D56CE3 /* MeshtasticSchemaV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticSchemaV1.swift; sourceTree = ""; }; + BCCA0BB12F1D0001007648E5 /* MeshtasticSchemaV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticSchemaV2.swift; sourceTree = ""; }; 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = ""; }; 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = ""; }; 0E644AE784C52500A9241481 /* SearchForMessagesIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchForMessagesIntentHandler.swift; sourceTree = ""; }; @@ -629,6 +633,7 @@ 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = ""; }; 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = ""; }; 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = ""; }; + DD4074682F1233F400BCC22F /* ExchangeUserInfoButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangeUserInfoButton.swift; sourceTree = ""; }; 2519268B2C3BB52000249DF5 /* TraceRouteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraceRouteButton.swift; sourceTree = ""; }; 2519268F2C3CB44900249DF5 /* ClientHistoryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientHistoryButton.swift; sourceTree = ""; }; 251926912C3CB52300249DF5 /* DeleteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteNodeButton.swift; sourceTree = ""; }; @@ -756,6 +761,9 @@ BCB613822C672A2600485544 /* MessageChannelIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageChannelIntent.swift; sourceTree = ""; }; BCB613842C68703800485544 /* NodePositionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodePositionIntent.swift; sourceTree = ""; }; BCB613862C69A0FB00485544 /* AppIntentErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentErrors.swift; sourceTree = ""; }; + BCCA0BAA2F1C5C25007648E5 /* LocalStatsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalStatsLog.swift; sourceTree = ""; }; + BCCA0BAC2F1C5C60007648E5 /* RequestLocalStatsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestLocalStatsButton.swift; sourceTree = ""; }; + BCCA0BB32F1D0101007648E5 /* UIKeyboardType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKeyboardType.swift; sourceTree = ""; }; BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactURLHandler.swift; sourceTree = ""; }; BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisconnectNodeIntent.swift; sourceTree = ""; }; BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; @@ -771,7 +779,6 @@ D93068D42B812B700066FBC8 /* MessageDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDestination.swift; sourceTree = ""; }; D93068D62B8146690066FBC8 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = ""; }; D93068D82B81509C0066FBC8 /* TapbackResponses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackResponses.swift; sourceTree = ""; }; - D93068D92B81509D0066FBC8 /* TapbackInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackInputView.swift; sourceTree = ""; }; D93068DA2B81C85E0066FBC8 /* PowerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerConfig.swift; sourceTree = ""; }; D93068DC2B81CA820066FBC8 /* ConfigHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigHeader.swift; sourceTree = ""; }; D93069062B81D8900066FBC8 /* MeshtasticDataModelV 27.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 27.xcdatamodel"; sourceTree = ""; }; @@ -894,7 +901,6 @@ DD3CC6C128EB9D4900FA9159 /* UpdateSwiftData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateSwiftData.swift; sourceTree = ""; }; DD3D17DC2C3D7B1400561584 /* MeshtasticDataModelV 39.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 39.xcdatamodel"; sourceTree = ""; }; DD3D17DF2C3FB67200561584 /* LocalWeatherConditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalWeatherConditions.swift; sourceTree = ""; }; - DD4074682F1233F400BCC22F /* ExchangeUserInfoButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangeUserInfoButton.swift; sourceTree = ""; }; DD41582528582E9B009B0E59 /* DeviceConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceConfig.swift; sourceTree = ""; }; DD415827285859C4009B0E59 /* TelemetryConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryConfig.swift; sourceTree = ""; }; DD41582928585C32009B0E59 /* RangeTestConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeTestConfig.swift; sourceTree = ""; }; @@ -953,6 +959,7 @@ DD93800A2BA3F968008BEC06 /* NodeMapContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeMapContent.swift; sourceTree = ""; }; DD93800D2BA74D0C008BEC06 /* ChannelForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelForm.swift; sourceTree = ""; }; DD94B73F2ACCE3BE00DCD1D1 /* MapSettingsForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapSettingsForm.swift; sourceTree = ""; }; + DD964FBC296E6B01007C176F /* EmojiOnlyTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiOnlyTextField.swift; sourceTree = ""; }; DD964FC029724F6D007C176F /* MeshtasticDataModelV6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV6.xcdatamodel; sourceTree = ""; }; DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaypointEntityExtension.swift; sourceTree = ""; }; DD964FC52975DBFD007C176F /* QuerySwiftData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuerySwiftData.swift; sourceTree = ""; }; @@ -971,7 +978,6 @@ DDA0B6B1294CDC55001356EC /* Channels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Channels.swift; sourceTree = ""; }; DDA1C48D28DB49D3009933EC /* ChannelRoles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRoles.swift; sourceTree = ""; }; DDA28B1B2D32C89200EF726F /* MeshtasticDataModelV 48.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 48.xcdatamodel"; sourceTree = ""; }; - DDA3DFD92F10B39600D8F103 /* UIKeyboardType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKeyboardType.swift; sourceTree = ""; }; DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPackets.swift; sourceTree = ""; }; DDA951592BC6624100CEA535 /* TelemetryWeather.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TelemetryWeather.swift; sourceTree = ""; }; DDA9515B2BC6631200CEA535 /* TelemetryEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryEnums.swift; sourceTree = ""; }; @@ -1469,10 +1475,11 @@ 251926882C3BAF2E00249DF5 /* Actions */ = { isa = PBXGroup; children = ( - DD4074682F1233F400BCC22F /* ExchangeUserInfoButton.swift */, + BCCA0BAC2F1C5C60007648E5 /* RequestLocalStatsButton.swift */, DDDFE73E2D0D48FF0044463C /* IgnoreNodeButton.swift */, 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */, 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */, + DD4074682F1233F400BCC22F /* ExchangeUserInfoButton.swift */, DDF82CBC2D5BC69200DC25EC /* NavigateToButton.swift */, 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */, 2519268B2C3BB52000249DF5 /* TraceRouteButton.swift */, @@ -1690,6 +1697,7 @@ isa = PBXGroup; children = ( 05E0BD1A81CF4F6A90D56CE3 /* MeshtasticSchemaV1.swift */, + BCCA0BB12F1D0001007648E5 /* MeshtasticSchemaV2.swift */, 1CAEAD87807B4F34820A60C2 /* MeshtasticMigrationPlan.swift */, ); path = Schema; @@ -1745,6 +1753,7 @@ DD47E3CA26F0E50300029299 /* Nodes */ = { isa = PBXGroup; children = ( + BCCA0BAA2F1C5C25007648E5 /* LocalStatsLog.swift */, DDDB26402AABEF7B003AFCB7 /* Helpers */, DDDB263E2AABEE20003AFCB7 /* NodeList.swift */, DD769E0228D18BF0001A3F05 /* DeviceMetricsLog.swift */, @@ -2132,7 +2141,6 @@ D93068D62B8146690066FBC8 /* MessageText.swift */, D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */, D93068D82B81509C0066FBC8 /* TapbackResponses.swift */, - D93068D92B81509D0066FBC8 /* TapbackInputView.swift */, ); path = Messages; sourceTree = ""; @@ -2264,8 +2272,8 @@ DDDB444D29F8AB0E00EE2349 /* Int.swift */, DDDB444729F8A9C900EE2349 /* String.swift */, DD77093E2AA1B146007A8BF0 /* UIColor.swift */, + BCCA0BB32F1D0101007648E5 /* UIKeyboardType.swift */, DDDB444F29F8AC9C00EE2349 /* UIImage.swift */, - DDA3DFD92F10B39600D8F103 /* UIKeyboardType.swift */, DDDB443F29F79AB000EE2349 /* UserDefaults.swift */, DDB75A0E2A05920E006ED576 /* FileManager.swift */, DDB75A102A059258006ED576 /* Url.swift */, @@ -2679,6 +2687,7 @@ AA00SDBF0003 /* FirmwareReleaseEntity.swift in Sources */, 10D80CF7AD2B4269BE0A4933 /* MeshtasticSchema.swift in Sources */, 6467579F9E9E4B0EAA009615 /* MeshtasticSchemaV1.swift in Sources */, + BCCA0BB22F1D0001007648E5 /* MeshtasticSchemaV2.swift in Sources */, 74BEA4429B3945AEBA11E977 /* MeshtasticMigrationPlan.swift in Sources */, D75A5D73360C42D6B0CB2894 /* MessageEntity.swift in Sources */, 85D0F28AE7CE44C39B6BEBF8 /* MyInfoEntity.swift in Sources */, @@ -2751,6 +2760,7 @@ 237AEB932E1FE4BA003B7CE3 /* Connection.swift in Sources */, DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */, 237AEB952E1FE516003B7CE3 /* Device.swift in Sources */, + BCCA0BAD2F1C5C60007648E5 /* RequestLocalStatsButton.swift in Sources */, 2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */, 233E99B82D849C6500CC3A77 /* HumidityCompactWidget.swift in Sources */, DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */, @@ -2837,7 +2847,6 @@ DDDB444A29F8AA3A00EE2349 /* CLLocationCoordinate2D.swift in Sources */, 25C49D902C471AEA0024FBD1 /* Constants.swift in Sources */, ABB99DEB2E2EA1C500CFBD05 /* AppIconPicker.swift in Sources */, - DD4074692F1233F400BCC22F /* ExchangeUserInfoButton.swift in Sources */, DD41582628582E9B009B0E59 /* DeviceConfig.swift in Sources */, DDF45C372BC46A5A005ED5F2 /* TimeZone.swift in Sources */, DD007BAE2AA4E91200F5FA12 /* MyInfoEntityExtension.swift in Sources */, @@ -2911,6 +2920,7 @@ ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */, DD1B8F402B35E2F10022AABC /* GPSStatus.swift in Sources */, 231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */, + BCCA0BAB2F1C5C25007648E5 /* LocalStatsLog.swift in Sources */, 231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */, DD8ED9C52898D51F00B3B0AB /* NetworkConfig.swift in Sources */, DDDE5A1029AFE69700490C6C /* MeshActivityAttributes.swift in Sources */, @@ -2931,7 +2941,6 @@ DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */, DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */, BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */, - DDA3DFDA2F10B39600D8F103 /* UIKeyboardType.swift in Sources */, DDB75A212A12B954006ED576 /* LoRaSignalStrength.swift in Sources */, DD6193752862F6E600E59241 /* ExternalNotificationConfig.swift in Sources */, DD268D8E2BCC90E2008073AE /* RouteEnums.swift in Sources */, @@ -2942,11 +2951,11 @@ AA00010A2F0CC00000600001 /* AudioConfig.swift in Sources */, 3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */, D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */, - D93068DA2B81509D0066FBC8 /* TapbackInputView.swift in Sources */, DDA9F5E82E77FAC100E70DEB /* AnimatedNodePin.swift in Sources */, DDF82CBD2D5BC69200DC25EC /* NavigateToButton.swift in Sources */, 8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */, 2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */, + DD4074692F1233F400BCC22F /* ExchangeUserInfoButton.swift in Sources */, DD86D40A287F04F100BAEB7A /* InvalidVersion.swift in Sources */, 233E99BE2D849D3200CC3A77 /* RadiationCompactWidget.swift in Sources */, DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */, @@ -2989,6 +2998,7 @@ 237AEB8F2E1FE457003B7CE3 /* Transport.swift in Sources */, DDDB26442AAC0206003AFCB7 /* NodeDetail.swift in Sources */, DD77093F2AA1B146007A8BF0 /* UIColor.swift in Sources */, + BCCA0BB42F1D0101007648E5 /* UIKeyboardType.swift in Sources */, DDF6B2482A9AEBF500BA6931 /* StoreForwardConfig.swift in Sources */, 233E99C72D84A70900CC3A77 /* SoilCompactWidgets.swift in Sources */, BCE2D3C92C7C377F008E6199 /* FactoryResetNodeIntent.swift in Sources */, @@ -3377,8 +3387,7 @@ OTHER_LDFLAGS = ( "-weak_framework", SwiftUI, - ); - PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; + ); PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -3426,8 +3435,7 @@ OTHER_LDFLAGS = ( "-weak_framework", SwiftUI, - ); - PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; + ); PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index 0ea546416..44bdd668b 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -2401,4 +2401,40 @@ extension AccessoryManager { return Int64(meshPacket.id) } + + func sendLocalStatsRequest(destNum: Int64, wantResponse: Bool) async throws { + guard let fromNodeNum = self.activeConnection?.device.num else { + Logger.services.error("Error while sending local stats request. No active device.") + throw AccessoryError.ioFailed("No active device") + } + + var telemetryPacket = Telemetry() + telemetryPacket.localStats = LocalStats() + + var meshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..(telemetry: S, metricsType: Int) -> String w csvString += ", " csvString += dm.time?.formatted(date: .numeric, time: .shortened).replacing(",", with: "") ?? "" } + } else if metricsType == 4 { + // Create Local Stats Header + csvString = "Noise Floor, Uptime, Relayed, Canceled, Dupes, Packets Tx, Packets Rx, Bad Rx, Nodes Online, Total Nodes, \("Timestamp".localized)" + for dm in telemetry where dm.metricsType == 4 { + csvString += "\n" + csvString += dm.noiseFloor?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.uptimeSeconds?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.numTxRelay.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numTxRelayCanceled.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numRxDupe.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numPacketsTx.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numPacketsRx.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numPacketsRxBad.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numOnlineNodes.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numTotalNodes.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.time?.formatted(date: .numeric, time: .shortened).replacing(",", with: "") ?? "" + } } return csvString } diff --git a/Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift b/Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift index 01abb35d0..4dfdfc8db 100644 --- a/Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift +++ b/Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift @@ -127,6 +127,28 @@ extension NodeInfoEntity { return (try? ctx.fetchCount(descriptor)) ?? 0 > 0 } + var latestLocalStats: TelemetryEntity? { + guard let ctx = modelContext else { return nil } + let nodeNum = self.num + let metricsType: Int32 = 4 + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.nodeTelemetry?.num == nodeNum && $0.metricsType == metricsType }, + sortBy: [SortDescriptor(\TelemetryEntity.time, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try? ctx.fetch(descriptor).first + } + + var hasLocalStats: Bool { + guard let ctx = modelContext else { return false } + let nodeNum = self.num + let metricsType: Int32 = 4 + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.nodeTelemetry?.num == nodeNum && $0.metricsType == metricsType } + ) + return (try? ctx.fetchCount(descriptor)) ?? 0 > 0 + } + var hasTraceRoutes: Bool { guard let ctx = modelContext else { return false } let nodeNum = self.num diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 4c3d17ecb..2e8c752c3 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -169,7 +169,7 @@ actor MeshPackets { #endif } } - + func moduleConfig (config: ModuleConfig, nodeNum: Int64, nodeLongName: String) { switch config.payloadVariant { case .ambientLighting: @@ -206,19 +206,19 @@ actor MeshPackets { #endif } } - + func myInfoPacket (myInfo: MyNodeInfo, peripheralId: String) -> PersistentIdentifier? { let logString = String.localizedStringWithFormat("MyInfo received: %@".localized, String(myInfo.myNodeNum)) Logger.mesh.info("ℹ️ \(logString, privacy: .public)") - + let myNodeNum = Int64(myInfo.myNodeNum) let fetchDescriptor = FetchDescriptor(predicate: #Predicate { $0.myNodeNum == myNodeNum }) - + do { let fetchedMyInfo = try modelContext.fetch(fetchDescriptor) // Not Found Insert if fetchedMyInfo.isEmpty { - + let myInfoEntity = MyInfoEntity() modelContext.insert(myInfoEntity) myInfoEntity.peripheralId = peripheralId @@ -232,14 +232,14 @@ actor MeshPackets { savePendingChanges() return myInfoEntity.persistentModelID } else { - + fetchedMyInfo[0].peripheralId = peripheralId fetchedMyInfo[0].myNodeNum = Int64(myInfo.myNodeNum) fetchedMyInfo[0].rebootCount = Int32(myInfo.rebootCount) if !myInfo.pioEnv.isEmpty { fetchedMyInfo[0].pioEnv = myInfo.pioEnv } - + Logger.data.info("πŸ’Ύ Updated myInfo for node: \(myInfo.myNodeNum.toHex(), privacy: .public)") savePendingChanges() return fetchedMyInfo[0].persistentModelID @@ -249,14 +249,14 @@ actor MeshPackets { } return nil } - + func channelPacket (channel: Channel, fromNum: Int64) { if channel.isInitialized && channel.hasSettings && channel.role != Channel.Role.disabled { let logString = String.localizedStringWithFormat("mesh.log.channel.received %d %@".localized, channel.index, String(fromNum)) Logger.mesh.info("πŸŽ›οΈ \(logString, privacy: .public)") - + let fetchDescriptor = FetchDescriptor(predicate: #Predicate { $0.myNodeNum == fromNum }) - + do { let fetchedMyInfo = try modelContext.fetch(fetchDescriptor) if fetchedMyInfo.count == 1 { @@ -291,14 +291,14 @@ actor MeshPackets { } } } - + func deviceMetadataPacket (metadata: DeviceMetadata, fromNum: Int64, sessionPasskey: Data? = Data()) { if metadata.isInitialized { let logString = String.localizedStringWithFormat("Device Metadata received from: %@".localized, fromNum.toHex()) Logger.mesh.info("🏷️ \(logString, privacy: .public)") - + let fetchDescriptor = FetchDescriptor(predicate: #Predicate { $0.num == fromNum }) - + do { let fetchedNode = try modelContext.fetch(fetchDescriptor) let newMetadata = DeviceMetadataEntity() @@ -337,21 +337,21 @@ actor MeshPackets { } } } - + func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, deferSave: Bool = false) -> PersistentIdentifier? { let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, String(nodeInfo.num)) Logger.mesh.info("πŸ“Ÿ \(logString, privacy: .public)") - + guard nodeInfo.num > 0 else { return nil } - + let nodeNum = Int64(nodeInfo.num) let fetchDescriptor = FetchDescriptor(predicate: #Predicate { $0.num == nodeNum }) - + do { let fetchedNode = try modelContext.fetch(fetchDescriptor) // Not Found Insert if fetchedNode.isEmpty && nodeInfo.num > 0 { - + let newNode = NodeInfoEntity() modelContext.insert(newNode) newNode.id = Int64(nodeInfo.num) @@ -360,7 +360,7 @@ actor MeshPackets { newNode.favorite = nodeInfo.isFavorite newNode.ignored = nodeInfo.isIgnored newNode.hopsAway = Int32(nodeInfo.hopsAway) - + if nodeInfo.hasDeviceMetrics { let telemetry = TelemetryEntity() modelContext.insert(telemetry) @@ -379,7 +379,7 @@ actor MeshPackets { } newNode.snr = nodeInfo.snr if nodeInfo.hasUser { - + let newUser = UserEntity() modelContext.insert(newUser) newUser.userId = nodeInfo.num.toHex() @@ -423,7 +423,7 @@ actor MeshPackets { Logger.data.error("Error Creating a new UserEntity from node number: \(nodeInfo.num, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") } } - + if (nodeInfo.position.longitudeI != 0 && nodeInfo.position.latitudeI != 0) && (nodeInfo.position.latitudeI != 373346000 && nodeInfo.position.longitudeI != -1220090000) { let position = PositionEntity() modelContext.insert(position) @@ -438,11 +438,11 @@ actor MeshPackets { position.time = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.position.time))) position.nodePosition = newNode } - + // Look for a MyInfo let myInfoNodeNum = Int64(nodeInfo.num) let fetchMyInfoDescriptor = FetchDescriptor(predicate: #Predicate { $0.myNodeNum == myInfoNodeNum }) - + do { let fetchedMyInfo = try modelContext.fetch(fetchMyInfoDescriptor) if fetchedMyInfo.count > 0 { @@ -457,7 +457,7 @@ actor MeshPackets { Logger.data.error("Fetch MyInfo Error") } } else if nodeInfo.num > 0 { - + fetchedNode[0].id = Int64(nodeInfo.num) fetchedNode[0].num = Int64(nodeInfo.num) if nodeInfo.lastHeard > 0 { @@ -471,7 +471,7 @@ actor MeshPackets { fetchedNode[0].favorite = nodeInfo.isFavorite fetchedNode[0].ignored = nodeInfo.isIgnored fetchedNode[0].hopsAway = Int32(nodeInfo.hopsAway) - + if nodeInfo.hasUser { if fetchedNode[0].user == nil { let newUserEntity = UserEntity() @@ -525,9 +525,9 @@ actor MeshPackets { } } } - + if nodeInfo.hasDeviceMetrics { - + let newTelemetry = TelemetryEntity() modelContext.insert(newTelemetry) newTelemetry.batteryLevel = Int32(nodeInfo.deviceMetrics.batteryLevel) @@ -536,11 +536,11 @@ actor MeshPackets { newTelemetry.airUtilTx = nodeInfo.deviceMetrics.airUtilTx newTelemetry.nodeTelemetry = fetchedNode[0] } - + if nodeInfo.hasPosition { - + if (nodeInfo.position.longitudeI != 0 && nodeInfo.position.latitudeI != 0) && (nodeInfo.position.latitudeI != 373346000 && nodeInfo.position.longitudeI != -1220090000) { - + let position = PositionEntity() modelContext.insert(position) position.latitudeI = nodeInfo.position.latitudeI @@ -550,13 +550,13 @@ actor MeshPackets { position.time = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.position.time))) position.nodePosition = fetchedNode[0] } - + } - + // Look for a MyInfo let myInfoNodeNum2 = Int64(nodeInfo.num) let fetchMyInfoDescriptor2 = FetchDescriptor(predicate: #Predicate { $0.myNodeNum == myInfoNodeNum2 }) - + do { let fetchedMyInfo = try modelContext.fetch(fetchMyInfoDescriptor2) if fetchedMyInfo.count > 0 { @@ -576,19 +576,19 @@ actor MeshPackets { } return nil } - + func adminAppPacket (packet: MeshPacket) { if let adminMessage = try? AdminMessage(serializedBytes: packet.decoded.payload) { - + if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getCannedMessageModuleMessagesResponse(adminMessage.getCannedMessageModuleMessagesResponse) { - + if let cmmc = try? CannedMessageModuleConfig(serializedBytes: packet.decoded.payload) { let logString = String.localizedStringWithFormat("Canned Messages Messages Received For: %@".localized, packet.from.toHex()) Logger.mesh.info("πŸ₯« \(logString, privacy: .public)") - + let packetFrom = Int64(packet.from) let fetchDescriptor = FetchDescriptor(predicate: #Predicate { $0.num == packetFrom }) - + do { let fetchedNode = try modelContext.fetch(fetchDescriptor) if fetchedNode.count == 1 { @@ -666,7 +666,7 @@ actor MeshPackets { self.adminResponseAck(packet: packet) } } - + private func adminResponseAck (packet: MeshPacket) { let requestID = Int64(packet.decoded.requestID) let fetchDescriptor = FetchDescriptor(predicate: #Predicate { $0.messageId == requestID }) @@ -679,33 +679,33 @@ actor MeshPackets { fetchedMessage[0].realACK = true fetchedMessage[0].relayNode = Int64(packet.relayNode) fetchedMessage[0].ackSNR = packet.rxSnr - + savePendingChanges() } } catch { Logger.data.error("Failed to fetch admin message by requestID: \(error.localizedDescription, privacy: .public)") } } - + func paxCounterPacket (packet: MeshPacket) { let logString = String.localizedStringWithFormat("PAX Counter message received from: %@".localized, String(packet.from)) Logger.mesh.info("πŸ§‘β€πŸ€β€πŸ§‘ \(logString, privacy: .public)") - + let packetFrom = Int64(packet.from) let fetchDescriptor = FetchDescriptor(predicate: #Predicate { $0.num == packetFrom }) - + do { let fetchedNode = try modelContext.fetch(fetchDescriptor) - + if let paxMessage = try? Paxcount(serializedBytes: packet.decoded.payload) { - + let newPax = PaxCounterEntity() modelContext.insert(newPax) newPax.ble = Int32(truncatingIfNeeded: paxMessage.ble) newPax.wifi = Int32(truncatingIfNeeded: paxMessage.wifi) newPax.uptime = Int32(truncatingIfNeeded: paxMessage.uptime) newPax.time = Date() - + if fetchedNode.count > 0 { fetchedNode[0].pax.append(newPax) savePendingChanges() @@ -714,22 +714,22 @@ actor MeshPackets { } } } catch { - + } } - + func routingPacket (packet: MeshPacket, connectedNodeNum: Int64) { if let routingMessage = try? Routing(serializedBytes: packet.decoded.payload) { - + let routingError = RoutingError(rawValue: routingMessage.errorReason.rawValue) - + let routingErrorString = routingError?.display ?? "Unknown".localized let logString = String.localizedStringWithFormat("Routing received for RequestID: %@ Ack Status: %@".localized, String(packet.decoded.requestID), routingErrorString) Logger.mesh.info("πŸ•ΈοΈ \(logString, privacy: .public)") - + let requestID = Int64(packet.decoded.requestID) let fetchDescriptor = FetchDescriptor(predicate: #Predicate { $0.messageId == requestID }) - + do { let fetchedMessage = try modelContext.fetch(fetchDescriptor) if fetchedMessage.count > 0 { @@ -745,14 +745,14 @@ actor MeshPackets { fetchedMessage[0].receivedACK = true fetchedMessage[0].relays += 1 } - + fetchedMessage[0].ackSNR = packet.rxSnr if packet.rxTime > 0 { fetchedMessage[0].ackTimestamp = Int32(truncatingIfNeeded: packet.rxTime) } else { fetchedMessage[0].ackTimestamp = Int32(Date().timeIntervalSince1970) } - + } else { return } @@ -764,7 +764,7 @@ actor MeshPackets { } } } - + func telemetryPacket(packet: MeshPacket, connectedNode: Int64) { if let telemetryMessage = try? Telemetry(serializedBytes: packet.decoded.payload) { if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) && telemetryMessage.variant != Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { @@ -828,8 +828,9 @@ actor MeshPackets { telemetry.numTxRelayCanceled = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelayCanceled) telemetry.numOnlineNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numOnlineNodes) telemetry.numTotalNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTotalNodes) + telemetry.noiseFloor = telemetryMessage.localStats.noiseFloor telemetry.metricsType = 4 - Logger.statistics.info("πŸ“ˆ [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") + Logger.statistics.info("πŸ“ˆ [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Noise Floor: \(telemetryMessage.localStats.noiseFloor, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") } else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { Logger.data.info("πŸ“ˆ [Telemetry] Power Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage) @@ -903,7 +904,7 @@ actor MeshPackets { // Update our live activity if there is one running, not available on mac #if !targetEnvironment(macCatalyst) #if canImport(ActivityKit) - + let fifteenMinutesLater = Calendar.current.date(byAdding: .minute, value: (Int(15) ), to: Date())! let date = Date.now...fifteenMinutesLater let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: telemetry.uptimeSeconds.map { UInt32($0) }, @@ -918,10 +919,10 @@ actor MeshPackets { nodesOnline: UInt32(telemetry.numOnlineNodes), totalNodes: UInt32(telemetry.numTotalNodes), timerRange: date) - + let alertConfiguration = AlertConfiguration(title: "Mesh activity update", body: "Updated Node Stats Data.", sound: .default) let updatedContent = ActivityContent(state: updatedMeshStatus, staleDate: nil) - + let meshActivity = Activity.activities.first(where: { $0.attributes.nodeNum == connectedNode }) if meshActivity != nil { Task { @@ -941,7 +942,7 @@ actor MeshPackets { Logger.data.error("πŸ’₯ Error Fetching NodeInfoEntity for Node \(packet.from.toHex(), privacy: .public)") } } - + func textMessageAppPacket( packet: MeshPacket, wantRangeTestPackets: Bool, @@ -961,7 +962,7 @@ actor MeshPackets { } } let rangeTest = messageText?.contains(rangeTestRegex) ?? false && messageText?.starts(with: "seq ") ?? false - + if !wantRangeTestPackets && rangeTest { return } @@ -974,7 +975,7 @@ actor MeshPackets { } } } - + if messageText?.count ?? 0 > 0 { Logger.mesh.info("πŸ’¬ \("Message received from the text message app.".localized, privacy: .public)") let toNum = Int64(packet.to) @@ -1032,7 +1033,7 @@ actor MeshPackets { newMessage.pkiEncrypted = true newMessage.publicKey = packet.publicKey } - + /// Check for key mismatch if let nodeKey = newMessage.fromUser?.publicKey { if newMessage.toUser != nil && packet.pkiEncrypted && !packet.publicKey.isEmpty { @@ -1151,7 +1152,7 @@ actor MeshPackets { #endif manager.notifications = [dmNotification] manager.schedule() - + Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)") } } @@ -1199,11 +1200,11 @@ actor MeshPackets { } } } - + func waypointPacket (packet: MeshPacket) { let logString = String.localizedStringWithFormat("Waypoint Packet received from node: %@".localized, String(packet.from)) Logger.mesh.info("πŸ“ \(logString, privacy: .public)") - + do { if let waypointMessage = try? Waypoint(serializedBytes: packet.decoded.payload) { // Fetch waypoint by waypointMessage.id, not packet.id @@ -1243,7 +1244,7 @@ actor MeshPackets { waypoint.created = Date() savePendingChanges() Logger.data.info("πŸ’Ύ Added Node Waypoint App Packet For: \(waypoint.id, privacy: .public)") - + Task { @MainActor in let manager = LocalNotificationManager() let icon = String(UnicodeScalar(Int(waypoint.icon)) ?? "πŸ“") diff --git a/Meshtastic/Model/MeshtasticSchema.swift b/Meshtastic/Model/MeshtasticSchema.swift index 201293666..efbfe8964 100644 --- a/Meshtastic/Model/MeshtasticSchema.swift +++ b/Meshtastic/Model/MeshtasticSchema.swift @@ -13,11 +13,11 @@ import SwiftData enum MeshtasticSchema { /// The current (latest) versioned schema. static var current: any VersionedSchema.Type { - MeshtasticSchemaV1.self + MeshtasticSchemaV2.self } /// All model types from the current schema version. static var allModels: [any PersistentModel.Type] { -MeshtasticSchemaV1.models + MeshtasticSchemaV2.models } } diff --git a/Meshtastic/Model/Schema/MeshtasticMigrationPlan.swift b/Meshtastic/Model/Schema/MeshtasticMigrationPlan.swift index db8a7960e..204dd8c34 100644 --- a/Meshtastic/Model/Schema/MeshtasticMigrationPlan.swift +++ b/Meshtastic/Model/Schema/MeshtasticMigrationPlan.swift @@ -19,7 +19,8 @@ enum MeshtasticMigrationPlan: SchemaMigrationPlan { /// SwiftData uses this ordering to determine which migrations to apply. static var schemas: [any VersionedSchema.Type] { [ - MeshtasticSchemaV1.self + MeshtasticSchemaV1.self, + MeshtasticSchemaV2.self ] } @@ -30,20 +31,17 @@ enum MeshtasticMigrationPlan: SchemaMigrationPlan { /// Use `.custom` when you need to transform data programmatically. static var stages: [MigrationStage] { [ - // No migrations yet β€” V1 is the initial version. - // Future migrations go here, for example: - // migrateV1toV2, + migrateV1toV2 ] } // MARK: - Migration Stages - // Uncomment and adapt when adding V2: - // - // static let migrateV1toV2 = MigrationStage.lightweight( - // fromVersion: MeshtasticSchemaV1.self, - // toVersion: MeshtasticSchemaV2.self - // ) - // + + static let migrateV1toV2 = MigrationStage.lightweight( + fromVersion: MeshtasticSchemaV1.self, + toVersion: MeshtasticSchemaV2.self + ) + // For custom migrations that require data transformation: // // static let migrateV1toV2 = MigrationStage.custom( diff --git a/Meshtastic/Model/Schema/MeshtasticSchemaV1.swift b/Meshtastic/Model/Schema/MeshtasticSchemaV1.swift index 5469ad315..7f87c92c2 100644 --- a/Meshtastic/Model/Schema/MeshtasticSchemaV1.swift +++ b/Meshtastic/Model/Schema/MeshtasticSchemaV1.swift @@ -55,6 +55,7 @@ enum MeshtasticSchemaV1: VersionedSchema { SerialConfigEntity.self, StoreForwardConfigEntity.self, TAKConfigEntity.self, + TrafficManagementConfigEntity.self, TelemetryConfigEntity.self, // Discovery entities DiscoverySessionEntity.self, diff --git a/Meshtastic/Model/Schema/MeshtasticSchemaV2.swift b/Meshtastic/Model/Schema/MeshtasticSchemaV2.swift new file mode 100644 index 000000000..de146fd4e --- /dev/null +++ b/Meshtastic/Model/Schema/MeshtasticSchemaV2.swift @@ -0,0 +1,66 @@ +// +// MeshtasticSchemaV2.swift +// Meshtastic +// +// Adds LocalStats noise floor telemetry. +// + +import Foundation +import SwiftData + +enum MeshtasticSchemaV2: VersionedSchema { + static var versionIdentifier = Schema.Version(2, 0, 0) + + static var models: [any PersistentModel.Type] { + [ + // Core entities + NodeInfoEntity.self, + UserEntity.self, + MyInfoEntity.self, + MessageEntity.self, + ChannelEntity.self, + PositionEntity.self, + WaypointEntity.self, + DeviceMetadataEntity.self, + TelemetryEntity.self, + PaxCounterEntity.self, + TraceRouteEntity.self, + TraceRouteHopEntity.self, + RouteEntity.self, + LocationEntity.self, + // Device hardware & firmware entities + DeviceHardwareEntity.self, + DeviceHardwareImageEntity.self, + DeviceHardwareTagEntity.self, + FirmwareReleaseEntity.self, + // Config entities + AmbientLightingConfigEntity.self, + AudioConfigEntity.self, + BluetoothConfigEntity.self, + CannedMessageConfigEntity.self, + DetectionSensorConfigEntity.self, + DeviceConfigEntity.self, + DisplayConfigEntity.self, + ExternalNotificationConfigEntity.self, + LoRaConfigEntity.self, + MQTTConfigEntity.self, + NeighborInfoConfigEntity.self, + NetworkConfigEntity.self, + PaxCounterConfigEntity.self, + PositionConfigEntity.self, + PowerConfigEntity.self, + RangeTestConfigEntity.self, + RTTTLConfigEntity.self, + SecurityConfigEntity.self, + SerialConfigEntity.self, + StoreForwardConfigEntity.self, + TAKConfigEntity.self, + TrafficManagementConfigEntity.self, + TelemetryConfigEntity.self, + // Discovery entities + DiscoverySessionEntity.self, + DiscoveryPresetResultEntity.self, + DiscoveredNodeEntity.self + ] + } +} diff --git a/Meshtastic/Model/TelemetryEntity.swift b/Meshtastic/Model/TelemetryEntity.swift index 318b7d658..10487e66c 100644 --- a/Meshtastic/Model/TelemetryEntity.swift +++ b/Meshtastic/Model/TelemetryEntity.swift @@ -34,6 +34,7 @@ final class TelemetryEntity { var iaq: Int32? var irLux: Float? var lux: Float? + var noiseFloor: Int32? var powerCh1Current: Float? var powerCh1Voltage: Float? var powerCh2Current: Float? diff --git a/Meshtastic/Persistence/NodeBackupManager+Import.swift b/Meshtastic/Persistence/NodeBackupManager+Import.swift index ee4c6491c..c79cc4ff1 100644 --- a/Meshtastic/Persistence/NodeBackupManager+Import.swift +++ b/Meshtastic/Persistence/NodeBackupManager+Import.swift @@ -180,6 +180,7 @@ extension NodeBackupManager { dst.numTotalNodes = src.numTotalNodes dst.numTxRelay = src.numTxRelay dst.numTxRelayCanceled = src.numTxRelayCanceled + dst.noiseFloor = src.noiseFloor dst.powerCh1Current = src.powerCh1Current dst.powerCh1Voltage = src.powerCh1Voltage dst.powerCh2Current = src.powerCh2Current diff --git a/Meshtastic/Resources/docs/index.json b/Meshtastic/Resources/docs/index.json index 6be74858c..f0aa654ee 100644 --- a/Meshtastic/Resources/docs/index.json +++ b/Meshtastic/Resources/docs/index.json @@ -358,6 +358,7 @@ "keywords": [ "node", "from", + "packets", "nodes", "key", "device", @@ -384,10 +385,9 @@ "short", "sensor", "row", - "router", - "reduces" + "router" ], - "charCount": 3885 + "charCount": 4406 }, { "id": "settings", @@ -512,11 +512,12 @@ "section": "user", "navOrder": 8, "keywords": [ + "node", "telemetry", "sensor", - "node", "quality", "air", + "packets", "environment", "data", "humidity", @@ -527,23 +528,22 @@ "metrics", "device", "description", + "readings", "power", + "nodes", "interval", "from", "channel", "wind", + "stats", "speed", + "reported", "relative", - "readings", "often", - "meshtastic", - "low", - "level", - "icon", - "hpa", - "detection" + "noise", + "meshtastic" ], - "charCount": 3248 + "charCount": 3882 }, { "id": "translate", diff --git a/Meshtastic/Resources/docs/markdown/user/nodes.md b/Meshtastic/Resources/docs/markdown/user/nodes.md index 8e1499ffa..ed42b4325 100644 --- a/Meshtastic/Resources/docs/markdown/user/nodes.md +++ b/Meshtastic/Resources/docs/markdown/user/nodes.md @@ -116,6 +116,12 @@ Tap a node and scroll to the Logs section for detailed metrics: | ![Detection Sensor](../assets/screenshots/logDetectionSensor.png) | Motion or door open/close alerts from the node. | | ![Trace Routes](../assets/screenshots/logTraceRoutes.png) | Recorded trace route paths showing the hops a message took through the mesh. | +## Local Stats and Noise Floor + +Local Stats show radio diagnostics reported by a node, including packets received, packets transmitted, duplicate packets, relayed packets, bad receives, canceled packets, online node count, total node count, and noise floor. + +Noise floor is displayed in dBm when the node reports it. Treat it as a directional diagnostic instead of an absolute site score: readings can vary quickly, and external filters can lower or skew the displayed value because of insertion loss or in-band interference. + ## Node Detail View Tap any node to see the full detail view with hardware info, signal metrics, environment sensors, and log navigation: diff --git a/Meshtastic/Resources/docs/markdown/user/telemetry.md b/Meshtastic/Resources/docs/markdown/user/telemetry.md index 7081ae0eb..5b00ea339 100644 --- a/Meshtastic/Resources/docs/markdown/user/telemetry.md +++ b/Meshtastic/Resources/docs/markdown/user/telemetry.md @@ -13,6 +13,7 @@ Meshtastic nodes can report sensor data across the mesh, giving you visibility i | Type | Data | |------|------| | Device Metrics | Battery level, battery voltage, channel utilisation, airtime fraction | +| Local Stats | Packets received/transmitted, relayed packets, duplicate packets, bad receives, node counts, noise floor | | Environment | Temperature (Β°C/Β°F), relative humidity (%), barometric pressure (hPa) | | Air Quality | PM1.0, PM2.5, PM10 particulate counts (Β΅g/mΒ³) | | Power | Voltage and current readings from power monitoring sensors | @@ -27,6 +28,12 @@ Meshtastic nodes can report sensor data across the mesh, giving you visibility i | ![Battery unknown](../assets/screenshots/batteryNil.png) | Unknown | Battery level not reported by this node. | | ![Battery plugged in](../assets/screenshots/batteryPluggedIn.png) | Plugged In | Node is powered via USB/external power. | +### Local Stats + +Local Stats are radio diagnostics reported by the node itself. They help diagnose mesh traffic and receiver conditions with counters for received packets, transmitted packets, relayed packets, duplicate packets, bad receives, canceled packets, online nodes, total nodes, and noise floor. + +Noise floor readings are shown in dBm when available. They can change quickly and should be interpreted with context: antenna direction, nearby interference, and external filters can all affect the displayed value. + ### Air Quality ![IAQ Scale](../assets/screenshots/iaqScale.png) diff --git a/Meshtastic/Resources/docs/user/nodes.html b/Meshtastic/Resources/docs/user/nodes.html index a3fc2106c..374efc8fa 100644 --- a/Meshtastic/Resources/docs/user/nodes.html +++ b/Meshtastic/Resources/docs/user/nodes.html @@ -213,6 +213,9 @@

Additional Icons

+

Local Stats and Noise Floor

+

Local Stats show radio diagnostics reported by a node, including packets received, packets transmitted, duplicate packets, relayed packets, bad receives, canceled packets, online node count, total node count, and noise floor.

+

Noise floor is displayed in dBm when the node reports it. Treat it as a directional diagnostic instead of an absolute site score: readings can vary quickly, and external filters can lower or skew the displayed value because of insertion loss or in-band interference.

Node Detail View

Tap any node to see the full detail view with hardware info, signal metrics, environment sensors, and log navigation:

Node Detail

diff --git a/Meshtastic/Resources/docs/user/telemetry.html b/Meshtastic/Resources/docs/user/telemetry.html index 49d85e34c..60980c870 100644 --- a/Meshtastic/Resources/docs/user/telemetry.html +++ b/Meshtastic/Resources/docs/user/telemetry.html @@ -23,6 +23,10 @@

Telemetry Types

Battery level, battery voltage, channel utilisation, airtime fraction +Local Stats +Packets received/transmitted, relayed packets, duplicate packets, bad receives, node counts, noise floor + + Environment Temperature (Β°C/Β°F), relative humidity (%), barometric pressure (hPa) @@ -73,6 +77,9 @@

Device Metrics

+

Local Stats

+

Local Stats are radio diagnostics reported by the node itself. They help diagnose mesh traffic and receiver conditions with counters for received packets, transmitted packets, relayed packets, duplicate packets, bad receives, canceled packets, online nodes, total nodes, and noise floor.

+

Noise floor readings are shown in dBm when available. They can change quickly and should be interpreted with context: antenna direction, nearby interference, and external filters can all affect the displayed value.

Air Quality

IAQ Scale

The Indoor Air Quality scale shows category bands from Excellent (green) through Hazardous (maroon). The app supports multiple display modes for air quality readings:

diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/RequestLocalStatsButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/RequestLocalStatsButton.swift new file mode 100644 index 000000000..27a1dfc3a --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Actions/RequestLocalStatsButton.swift @@ -0,0 +1,51 @@ +import SwiftUI +import OSLog + +struct RequestLocalStatsButton: View { + @EnvironmentObject var accessoryManager: AccessoryManager + + var node: NodeInfoEntity + + @State + private var isPresentingLocalStatsSentAlert: Bool = false + + var body: some View { + RateLimitedButton(key: "localstats", rateLimit: 30.0) { + Task { + do { + try await accessoryManager.sendLocalStatsRequest( + destNum: node.user?.num ?? 0, + wantResponse: true + ) + Task { @MainActor in + isPresentingLocalStatsSentAlert = true + } + } catch { + Logger.mesh.warning("Failed to send local stats request: \(error)") + } + } + } label: { completion in + if let completion, completion.percentComplete > 0.0 { + Label { + Text("Local Stats (in \(Int(completion.secondsRemaining))s)") + .foregroundStyle(.secondary) + } icon: { + Image("progress.ring.dashed", variableValue: completion.percentComplete) + .foregroundStyle(.secondary) + }.disabled(true) + } else { + Label { + Text("Request Local Stats") + } icon: { + Image(systemName: "chart.bar") + .symbolRenderingMode(.hierarchical) + } + } + } + .alert("Local Stats Requested", isPresented: $isPresentingLocalStatsSentAlert) { + Button("OK", role: .cancel) { } + } message: { + Text("A local stats request has been sent to \(node.user?.longName ?? "this node"). Responses can take some time.") + } + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift index 6a3c33f09..0b427b6b4 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift @@ -37,402 +37,29 @@ struct WaypointForm: View { var body: some View { NavigationStack { - Group { - if editMode { - Form { - if let cl = LocationsHandler.currentLocation { - let distance = CLLocation(latitude: cl.latitude, longitude: cl.longitude).distance(from: CLLocation(latitude: waypoint.mapCoordinate.latitude, longitude: waypoint.mapCoordinate.longitude )) - Section(header: Text("Coordinate") ) { - HStack { - Text("Location:") - .foregroundColor(.secondary) - Text("\(String(format: "%.5f", waypoint.mapCoordinate.latitude) + "," + String(format: "%.5f", waypoint.mapCoordinate.longitude))") - .textSelection(.enabled) - .foregroundColor(.secondary) - .font(.caption) - - } - Button { - waypoint.longitudeI = Int32(cl.longitude * 1e7) - waypoint.latitudeI = Int32(cl.latitude * 1e7) - } label: { - HStack { - Text("Use my Location") - Image(systemName: "location") - } - } - .accessibilityLabel("Set to current location") - HStack { - if waypoint.mapCoordinate.latitude != 0 && waypoint.mapCoordinate.longitude != 0 { - DistanceText(meters: distance) - .foregroundColor(Color.gray) - } - } - } - } - Section(header: Text("Waypoint Options")) { - HStack { - Text("Name") - Spacer() - TextField( - "Name", - text: $name, - axis: .vertical - ) - .foregroundColor(Color.gray) - .onChange(of: name) { - var totalBytes = name.utf8.count - // Only mess with the value if it is too big - while totalBytes > 30 { - name = String(name.dropLast()) - totalBytes = name.utf8.count - } - waypoint.name = name.count > 0 ? name : "Dropped Pin" - } - } - HStack { - Text("Description") - Spacer() - TextField( - "Description", - text: $description, - axis: .vertical - ) - .foregroundColor(Color.gray) - .onChange(of: description) { - var totalBytes = description.utf8.count - // Only mess with the value if it is too big - while totalBytes > 100 { - description = String(description.dropLast()) - totalBytes = description.utf8.count - } - } - } - HStack { - Text("Icon") - Spacer() - TextField("Select an emoji", text: $icon) - .keyboardType(.emoji) - .font(.system(size: 34)) - .focused($iconIsFocused) - .onChange(of: icon) { _, value in - // If a second emoji is entered delete the first one - if value.count >= 1 { - if value.count > 1 { - let index = value.index(value.startIndex, offsetBy: 1) - icon = String(value[index]) - } - } - } - } - Toggle(isOn: $expires) { - Label("Expires", systemImage: "clock.badge.xmark") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - if expires { - DatePicker("Expire", selection: $expire, in: Date.now...) - .datePickerStyle(.compact) - .font(.callout) - } - Toggle(isOn: $locked) { - Label("Locked", systemImage: "lock") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - } - } - .scrollContentBackground(.hidden) - .scrollDismissesKeyboard(.immediately) - HStack { - Button { - guard let deviceNum = accessoryManager.activeDeviceNum else { - Logger.mesh.warning("Send waypoint failed: No deviceNum") - return - } - if accessoryManager.isConnected { - /// Send a new or exiting waypoint - var newWaypoint = Waypoint() - if waypoint.id == 0 { - newWaypoint.id = UInt32.random(in: UInt32(UInt8.max).. 0 ? name : "Dropped Pin" - newWaypoint.description_p = description - let unicodeScalers = icon.unicodeScalars - let unicode = unicodeScalers.first?.value ?? 128205 - newWaypoint.icon = unicode - if locked { - if lockedTo == 0 { - newWaypoint.lockedTo = UInt32(deviceNum) - } else { - newWaypoint.lockedTo = UInt32(lockedTo) - } - } - if expires { - newWaypoint.expire = UInt32(expire.timeIntervalSince1970) - } else { - newWaypoint.expire = 0 - } - - Task { - do { - try await accessoryManager.sendWaypoint(waypoint: newWaypoint) - dismiss() - } catch { - Logger.mesh.warning("Send waypoint failed: \(error)") - Task { @MainActor in - waypointFailedAlert = true - } - } - } - } else { - Logger.mesh.warning("Send waypoint failed, node not connected") - } - } label: { - Label("Send", systemImage: "arrow.up") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.regular) - .disabled(!accessoryManager.isConnected) - .padding(.bottom) - - Button(role: .cancel) { - dismiss() - } label: { - Label("Cancel", systemImage: "x.circle") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.regular) - .padding(.bottom) - - if waypoint.id > 0 && accessoryManager.isConnected { - - Menu { - Button("For me", action: { - context.delete(waypoint) - do { - try context.save() - } catch { - } - dismiss() }) - Button("For everyone", action: { - guard let deviceNum = accessoryManager.activeDeviceNum else { - Logger.mesh.error("Unable to set waypoint: No Device num") - return - } - var newWaypoint = Waypoint() - newWaypoint.id = UInt32(waypoint.id) - newWaypoint.name = name.count > 0 ? name : "Dropped Pin" - newWaypoint.description_p = description - newWaypoint.latitudeI = waypoint.latitudeI - newWaypoint.longitudeI = waypoint.longitudeI - // Unicode scalar value for the icon emoji string - let unicodeScalers = icon.unicodeScalars - // First element as an UInt32 - let unicode = unicodeScalers[unicodeScalers.startIndex].value - newWaypoint.icon = unicode - if locked { - if lockedTo == 0 { - newWaypoint.lockedTo = UInt32(deviceNum) - } else { - newWaypoint.lockedTo = UInt32(lockedTo) - } - } - newWaypoint.expire = UInt32(1) - Task { - do { - try await accessoryManager.sendWaypoint(waypoint: newWaypoint) - Task { @MainActor in - context.delete(waypoint) - do { - try context.save() - } catch { - } - dismiss() - } - } catch { - Logger.mesh.warning("Send waypoint failed") - Task {@MainActor in - waypointFailedAlert = true - } - } - } - }) - } - label: { - Label("Delete", systemImage: "trash") - .foregroundColor(.red) - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.regular) - .padding(.bottom) - } - } - .navigationTitle((waypoint.id > 0) ? "Editing Waypoint" : "Create Waypoint") - .navigationBarTitleDisplayMode(.inline) - // end Form - } else { - List { - Section { - if let created = createdByNode { - HStack(spacing: 8) { - CircleText( - text: created.user?.shortName ?? "?", - color: Color(UIColor(hex: UInt32(created.user?.num ?? 0x808080))) - ) - VStack(alignment: .leading) { - Text("Created by") - .font(.caption) - .foregroundStyle(.secondary) - Text(created.user?.longName ?? "Unknown") - .font(.body) - } - } - } - - if let updated = lastUpdatedByNode { - HStack(spacing: 8) { - CircleText( - text: updated.user?.shortName ?? "?", - color: Color(UIColor(hex: UInt32(updated.user?.num ?? 0x808080))) - ) - VStack(alignment: .leading) { - Text("Last updated by") - .font(.caption) - .foregroundStyle(.secondary) - Text(updated.user?.longName ?? "Unknown") - .font(.body) - } - } - } - - if (waypoint.longDescription ?? "").count > 0 { - Label { - Text(waypoint.longDescription ?? "") - .foregroundColor(.primary) - .textSelection(.enabled) - } icon: { - Image(systemName: "doc.plaintext") - } - } - } header: { - HStack { - CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "πŸ“"), color: Color.orange, circleSize: 36) - Text(waypoint.name ?? "Waypoint") - .font(.headline) - } - } - - Section { - Label { - Text("\(String(format: "%.6f", waypoint.mapCoordinate.latitude)), \(String(format: "%.6f", waypoint.mapCoordinate.longitude))") - .textSelection(.enabled) - .foregroundColor(.secondary) - } icon: { - Image(systemName: "mappin.circle") - } - - if let cl = LocationsHandler.currentLocation { - let metersAway = waypoint.mapCoordinate.distance(from: cl) - if metersAway > 0.0 { - Label { - Text(distanceFormatter.string(fromDistance: Double(metersAway))) - } icon: { - Image(systemName: "lines.measurement.horizontal") - .symbolRenderingMode(.hierarchical) - } - } - } - - Button { - if let url = URL(string: "http://maps.apple.com/?ll=\(waypoint.mapCoordinate.latitude),\(waypoint.mapCoordinate.longitude)&q=\(waypoint.name ?? "Dropped Pin")") { - UIApplication.shared.open(url) - } - } label: { - Label("Open in Maps", systemImage: "mappin.and.ellipse") - } - } header: { - Text("Location") - } - - Section { - Label { - Text(waypoint.created?.formatted(date: .numeric, time: .shortened) ?? "?") - .foregroundStyle(.secondary) - } icon: { - Image(systemName: "clock.badge.checkmark") - .symbolRenderingMode(.hierarchical) - } - - if waypoint.lastUpdated != nil { - Label { - Text(waypoint.lastUpdated?.formatted(date: .numeric, time: .shortened) ?? "?") - .foregroundStyle(.secondary) - } icon: { - Image(systemName: "clock.arrow.circlepath") - .symbolRenderingMode(.hierarchical) - } - } - - if waypoint.expire != nil { - Label { - Text(waypoint.expire?.formatted(date: .numeric, time: .shortened) ?? "?") - .foregroundStyle(.secondary) - } icon: { - Image(systemName: "hourglass.bottomhalf.filled") - .symbolRenderingMode(.hierarchical) - } - } - } header: { - Text("Timestamps") - } - } - .navigationTitle(waypoint.name ?? "Waypoint") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if !waypoint.locked { - ToolbarItem(placement: .topBarTrailing) { - Button { - editMode = true - selectedDetent = .fraction(0.85) - } label: { - Image(systemName: "square.and.pencil") - } - } - } - } + formContent } - } // Group - } // NavigationStack .background(Color(.systemGroupedBackground)) .alert("Waypoint Failed to Send", isPresented: $waypointFailedAlert) { - Button("OK", role: .cancel) { - context.delete(waypoint) - do { - try context.save() - } catch { - } - dismiss() - } + Button("OK", role: .cancel) { + context.delete(waypoint) + do { + try context.save() + } catch { } + dismiss() + } + } .onDisappear { if waypoint.id == 0 { - // New, unsent waypoint created by the user: delete it - context.delete(waypoint) - do { - try context.save() - } catch { - Logger.mesh.error("Failed to save context on waypoint deletion: \(error)") - } + // New, unsent waypoint created by the user: delete it + context.delete(waypoint) + do { + try context.save() + } catch { + Logger.mesh.error("Failed to save context on waypoint deletion: \(error)") } + } } .task { await fetchNodeInfo() @@ -486,7 +113,7 @@ struct WaypointForm: View { } #endif } - + @MainActor private func fetchNodeInfo() async { // --- Fetch createdBy node --- @@ -522,4 +149,409 @@ struct WaypointForm: View { } } } + + @ViewBuilder + private var formContent: some View { + if editMode { + editContent + } else { + detailContent + } + } + + private var editContent: some View { + VStack(spacing: 0) { + editForm + editActions + } + .navigationTitle((waypoint.id > 0) ? "Editing Waypoint" : "Create Waypoint") + .navigationBarTitleDisplayMode(.inline) + } + + private var editForm: some View { + Form { + coordinateSection + waypointOptionsSection + } + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.immediately) + } + + @ViewBuilder + private var coordinateSection: some View { + if let cl = LocationsHandler.currentLocation { + let distance = CLLocation(latitude: cl.latitude, longitude: cl.longitude) + .distance(from: CLLocation(latitude: waypoint.mapCoordinate.latitude, longitude: waypoint.mapCoordinate.longitude)) + Section(header: Text("Coordinate")) { + HStack { + Text("Location:") + .foregroundColor(.secondary) + Text("\(String(format: "%.5f", waypoint.mapCoordinate.latitude) + "," + String(format: "%.5f", waypoint.mapCoordinate.longitude))") + .textSelection(.enabled) + .foregroundColor(.secondary) + .font(.caption) + } + Button { + waypoint.longitudeI = Int32(cl.longitude * 1e7) + waypoint.latitudeI = Int32(cl.latitude * 1e7) + } label: { + HStack { + Text("Use my Location") + Image(systemName: "location") + } + } + .accessibilityLabel("Set to current location") + HStack { + if waypoint.mapCoordinate.latitude != 0 && waypoint.mapCoordinate.longitude != 0 { + DistanceText(meters: distance) + .foregroundColor(Color.gray) + } + } + } + } + } + + private var waypointOptionsSection: some View { + Section(header: Text("Waypoint Options")) { + HStack { + Text("Name") + Spacer() + TextField( + "Name", + text: $name, + axis: .vertical + ) + .foregroundColor(Color.gray) + .onChange(of: name) { + var totalBytes = name.utf8.count + // Only mess with the value if it is too big + while totalBytes > 30 { + name = String(name.dropLast()) + totalBytes = name.utf8.count + } + waypoint.name = name.count > 0 ? name : "Dropped Pin" + } + } + HStack { + Text("Description") + Spacer() + TextField( + "Description", + text: $description, + axis: .vertical + ) + .foregroundColor(Color.gray) + .onChange(of: description) { + var totalBytes = description.utf8.count + // Only mess with the value if it is too big + while totalBytes > 100 { + description = String(description.dropLast()) + totalBytes = description.utf8.count + } + } + } + HStack { + Text("Icon") + Spacer() + TextField("Select an emoji", text: $icon) + .keyboardType(.emoji) + .font(.system(size: 34)) + .focused($iconIsFocused) + .onChange(of: icon) { _, value in + // If a second emoji is entered delete the first one + if value.count >= 1 { + if value.count > 1 { + let index = value.index(value.startIndex, offsetBy: 1) + icon = String(value[index]) + } + } + } + } + Toggle(isOn: $expires) { + Label("Expires", systemImage: "clock.badge.xmark") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + if expires { + DatePicker("Expire", selection: $expire, in: Date.now...) + .datePickerStyle(.compact) + .font(.callout) + } + Toggle(isOn: $locked) { + Label("Locked", systemImage: "lock") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + } + } + + private var editActions: some View { + HStack { + sendButton + cancelButton + if waypoint.id > 0 && accessoryManager.isConnected { + deleteMenu + } + } + } + + private var sendButton: some View { + Button { + sendWaypoint() + } label: { + Label("Send", systemImage: "arrow.up") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.regular) + .disabled(!accessoryManager.isConnected) + .padding(.bottom) + } + + private var cancelButton: some View { + Button(role: .cancel) { + dismiss() + } label: { + Label("Cancel", systemImage: "x.circle") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.regular) + .padding(.bottom) + } + + private var deleteMenu: some View { + Menu { + Button("For me", action: deleteWaypointForMe) + Button("For everyone", action: deleteWaypointForEveryone) + } label: { + Label("Delete", systemImage: "trash") + .foregroundColor(.red) + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.regular) + .padding(.bottom) + } + + private var detailContent: some View { + List { + metadataSection + locationSection + timestampSection + } + .navigationTitle(waypoint.name ?? "Waypoint") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !waypoint.locked { + ToolbarItem(placement: .topBarTrailing) { + Button { + editMode = true + selectedDetent = .fraction(0.85) + } label: { + Image(systemName: "square.and.pencil") + } + } + } + } + } + + private var metadataSection: some View { + Section { + if let created = createdByNode { + nodeCreditRow(title: "Created by", node: created) + } + + if let updated = lastUpdatedByNode { + nodeCreditRow(title: "Last updated by", node: updated) + } + + if (waypoint.longDescription ?? "").count > 0 { + Label { + Text(waypoint.longDescription ?? "") + .foregroundColor(.primary) + .textSelection(.enabled) + } icon: { + Image(systemName: "doc.plaintext") + } + } + } header: { + HStack { + CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "πŸ“"), color: Color.orange, circleSize: 36) + Text(waypoint.name ?? "Waypoint") + .font(.headline) + } + } + } + + private var locationSection: some View { + Section { + Label { + Text("\(String(format: "%.6f", waypoint.mapCoordinate.latitude)), \(String(format: "%.6f", waypoint.mapCoordinate.longitude))") + .textSelection(.enabled) + .foregroundColor(.secondary) + } icon: { + Image(systemName: "mappin.circle") + } + + if let cl = LocationsHandler.currentLocation { + let metersAway = waypoint.mapCoordinate.distance(from: cl) + if metersAway > 0.0 { + Label { + Text(distanceFormatter.string(fromDistance: Double(metersAway))) + } icon: { + Image(systemName: "lines.measurement.horizontal") + .symbolRenderingMode(.hierarchical) + } + } + } + + Button { + if let url = URL(string: "http://maps.apple.com/?ll=\(waypoint.mapCoordinate.latitude),\(waypoint.mapCoordinate.longitude)&q=\(waypoint.name ?? "Dropped Pin")") { + UIApplication.shared.open(url) + } + } label: { + Label("Open in Maps", systemImage: "mappin.and.ellipse") + } + } header: { + Text("Location") + } + } + + private var timestampSection: some View { + Section { + Label { + Text(waypoint.created?.formatted(date: .numeric, time: .shortened) ?? "?") + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "clock.badge.checkmark") + .symbolRenderingMode(.hierarchical) + } + + if waypoint.lastUpdated != nil { + Label { + Text(waypoint.lastUpdated?.formatted(date: .numeric, time: .shortened) ?? "?") + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "clock.arrow.circlepath") + .symbolRenderingMode(.hierarchical) + } + } + + if waypoint.expire != nil { + Label { + Text(waypoint.expire?.formatted(date: .numeric, time: .shortened) ?? "?") + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "hourglass.bottomhalf.filled") + .symbolRenderingMode(.hierarchical) + } + } + } header: { + Text("Timestamps") + } + } + + private func nodeCreditRow(title: LocalizedStringKey, node: NodeInfoEntity) -> some View { + HStack(spacing: 8) { + CircleText( + text: node.user?.shortName ?? "?", + color: Color(UIColor(hex: UInt32(node.user?.num ?? 0x808080))) + ) + VStack(alignment: .leading) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(node.user?.longName ?? "Unknown") + .font(.body) + } + } + } + + private func sendWaypoint() { + guard let deviceNum = accessoryManager.activeDeviceNum else { + Logger.mesh.warning("Send waypoint failed: No deviceNum") + return + } + guard accessoryManager.isConnected else { + Logger.mesh.warning("Send waypoint failed, node not connected") + return + } + + /// Send a new or exiting waypoint + var newWaypoint = Waypoint() + if waypoint.id == 0 { + newWaypoint.id = UInt32.random(in: UInt32(UInt8.max).. 0 ? name : "Dropped Pin" + newWaypoint.description_p = description + newWaypoint.icon = icon.unicodeScalars.first?.value ?? 128205 + if locked { + if lockedTo == 0 { + newWaypoint.lockedTo = UInt32(deviceNum) + } else { + newWaypoint.lockedTo = UInt32(lockedTo) + } + } + } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index faf0660b9..0de97a199 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -482,6 +482,17 @@ struct NodeDetail: View { } } .disabled(!node.hasDetectionSensorMetrics) + NavigationLink { + LocalStatsLog(node: node) + } label: { + Label { + Text("Local Stats Log") + } icon: { + Image(systemName: "chart.bar") + .symbolRenderingMode(.multicolor) + } + } + .disabled(!node.hasLocalStats) if node.hasPax { NavigationLink { PaxCounterLog(node: node) @@ -528,6 +539,7 @@ struct NodeDetail: View { node: node, connectedNode: connectedNode ) + RequestLocalStatsButton(node: node) ExchangeUserInfoButton( node: node, connectedNode: connectedNode diff --git a/Meshtastic/Views/Nodes/LocalStatsLog.swift b/Meshtastic/Views/Nodes/LocalStatsLog.swift new file mode 100644 index 000000000..46ff2134e --- /dev/null +++ b/Meshtastic/Views/Nodes/LocalStatsLog.swift @@ -0,0 +1,289 @@ +// +// LocalStatsLog.swift +// Meshtastic +// +// Copyright(c) Benjamin Faershtein 1/17/26. +// + +import SwiftUI +import Charts +import OSLog +import TipKit + +struct LocalStatsLog: View { + + @EnvironmentObject var accessoryManager: AccessoryManager + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + private let noiseFloorTip = NoiseFloorTip() + + @State private var isPresentingClearLogConfirm: Bool = false + @State var isExporting = false + @State var exportString = "" + + @Bindable var node: NodeInfoEntity + @State private var sortOrder = [KeyPathComparator(\TelemetryEntity.time, order: .reverse)] + @State private var selection: TelemetryEntity.ID? + @State private var chartSelection: Date? + + private var localStats: [TelemetryEntity] { + node.safeTelemetries(ofType: 4) + } + + private var chartData: [TelemetryEntity] { + let oneWeekAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date()) + return localStats + .filter { if let time = $0.time, let cutoff = oneWeekAgo { return time >= cutoff } else { return false } } + .sorted { ($0.time ?? .distantPast) < ($1.time ?? .distantPast) } + } + + private var dateFormatString: String { + let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMdjmma", options: 0, locale: Locale.current) + return (localeDateFormat ?? "M/d/YY j:mma").replacingOccurrences(of: ",", with: "") + } + + var body: some View { + VStack { + if node.hasLocalStats { + TipView(noiseFloorTip, arrowEdge: .top) + .tipViewStyle(PersistentTipStyle()) + .padding(.horizontal) + + if !chartData.isEmpty { + chartView + } + tableView + buttonView + } else { + ContentUnavailableView("No Local Stats", systemImage: "waveform") + } + } + .navigationTitle("Local Stats Log") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") + } + } + .fileExporter( + isPresented: $isExporting, + document: CsvDocument(emptyCsv: exportString), + contentType: .commaSeparatedText, + defaultFilename: String("\(node.user?.longName ?? "Node") \("Local Stats Log".localized) \(Date.now.exportTimestamp)"), + onCompletion: { result in + switch result { + case .success: + self.isExporting = false + Logger.services.info("Local stats log download succeeded.") + case .failure(let error): + Logger.services.error("Local stats log download failed: \(error.localizedDescription, privacy: .public)") + } + } + ) + } + + private var chartView: some View { + GroupBox(label: Label("\(localStats.count) Readings Total", systemImage: "waveform")) { + Chart(chartData) { point in + if let pointTime = point.time, let noiseFloor = point.noiseFloor { + LineMark( + x: .value("Time", pointTime), + y: .value("Noise Floor", Int(noiseFloor)) + ) + .foregroundStyle(Color.accentColor) + .interpolationMethod(.linear) + } + RuleMark(y: .value("Threshold (-85 dBm)", -85)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5])) + .foregroundStyle(.red) + } + .chartXAxis(content: { + AxisMarks(position: .top) + }) + .chartXSelection(value: $chartSelection) + .chartYScale(domain: -130 ... -60) + .chartForegroundStyleScale([ + "Noise Floor": Color.accentColor + ]) + .chartLegend(position: .automatic, alignment: .bottom) + } + .frame(minHeight: 240) + } + + @ViewBuilder + private var tableView: some View { + if idiom == .phone { + phoneTableView + } else { + macTableView + } + } + + private var phoneTableView: some View { + Table(localStats, selection: $selection, sortOrder: $sortOrder) { + TableColumn("Local Stats") { ls in + HStack { + Text(ls.time?.formattedDate(format: dateFormatString) ?? "Unknown Age".localized) + .font(.caption) + .fontWeight(.semibold) + Spacer() + } + HStack { + if let noiseFloor = ls.noiseFloor { + Text("Noise Floor \(noiseFloor) dBm") + .foregroundColor(noiseFloorColor(noiseFloor)) + } else { + Text("Noise Floor No Reading") + .foregroundColor(.gray) + } + Spacer() + } + HStack { + Text("Relayed: \(ls.numTxRelay)") + Text("Canceled: \(ls.numTxRelayCanceled)") + Text("Dupes: \(ls.numRxDupe)") + Spacer() + } + .font(.caption) + } + .width(ideal: 200, max: .infinity) + } + } + + private var macTableView: some View { + Table(localStats, selection: $selection, sortOrder: $sortOrder) { + TableColumn("Noise Floor") { ls in + if let noiseFloor = ls.noiseFloor { + Text("\(noiseFloor) dBm") + .foregroundColor(noiseFloorColor(noiseFloor)) + } else { + Text("No Reading") + .foregroundColor(.gray) + } + } + TableColumn("Uptime") { ls in + if let uptimeSeconds = ls.uptimeSeconds { + let now = Date.now + let later = now + TimeInterval(uptimeSeconds) + let components = (now.. Color { + if value < -95 { + return .green + } else if value < -90 { + return .orange + } else { + return .red + } + } +} + +private struct NoiseFloorTip: Tip { + var id: String { + "tip.localStats.noiseFloor" + } + + var title: Text { + Text("Noise Floor") + } + + var message: Text? { + Text("Noise floor is a directional diagnostic. Readings can vary quickly, and external filters can lower or skew the displayed value due to insertion loss or in-band interference.") + } + + var image: Image? { + Image(systemName: "waveform.path.ecg") + } + + var options: [TipOption] { + Tips.IgnoresDisplayFrequency(true) + Tips.MaxDisplayCount(3) + } +} diff --git a/MeshtasticTests/CarPlayTests.swift b/MeshtasticTests/CarPlayTests.swift index 67ff1c5ed..b7f0a2588 100644 --- a/MeshtasticTests/CarPlayTests.swift +++ b/MeshtasticTests/CarPlayTests.swift @@ -16,6 +16,7 @@ import Testing // MARK: - CarPlaySceneDelegate Tests +#if !targetEnvironment(simulator) @Suite("CarPlaySceneDelegate") struct CarPlaySceneDelegateTests { @@ -31,6 +32,7 @@ struct CarPlaySceneDelegateTests { #expect(delegate.interfaceController == nil) } } +#endif // MARK: - CarPlayIntentDonation Tests diff --git a/MeshtasticTests/MeshPacketsAndTelemetryTests.swift b/MeshtasticTests/MeshPacketsAndTelemetryTests.swift index e8d267b1c..abec5530d 100644 --- a/MeshtasticTests/MeshPacketsAndTelemetryTests.swift +++ b/MeshtasticTests/MeshPacketsAndTelemetryTests.swift @@ -69,6 +69,20 @@ struct GenerateMessageMarkdownTests { // MARK: - TelemetryEnums Aqi +@Suite("Local stats telemetry export") +struct LocalStatsTelemetryExportTests { + + @Test func csvPreservesZeroNoiseFloor() { + let telemetry = TelemetryEntity() + telemetry.metricsType = 4 + telemetry.noiseFloor = 0 + + let csv = telemetryToCsvFile(telemetry: [telemetry], metricsType: 4) + + #expect(csv.split(separator: "\n").last?.split(separator: ",").first?.trimmingCharacters(in: .whitespaces) == "0") + } +} + @Suite("Aqi getAqi boundary values") struct AqiGetAqiBoundaryTests { diff --git a/docs/user/nodes.md b/docs/user/nodes.md index 8e1499ffa..ed42b4325 100644 --- a/docs/user/nodes.md +++ b/docs/user/nodes.md @@ -116,6 +116,12 @@ Tap a node and scroll to the Logs section for detailed metrics: | ![Detection Sensor](../assets/screenshots/logDetectionSensor.png) | Motion or door open/close alerts from the node. | | ![Trace Routes](../assets/screenshots/logTraceRoutes.png) | Recorded trace route paths showing the hops a message took through the mesh. | +## Local Stats and Noise Floor + +Local Stats show radio diagnostics reported by a node, including packets received, packets transmitted, duplicate packets, relayed packets, bad receives, canceled packets, online node count, total node count, and noise floor. + +Noise floor is displayed in dBm when the node reports it. Treat it as a directional diagnostic instead of an absolute site score: readings can vary quickly, and external filters can lower or skew the displayed value because of insertion loss or in-band interference. + ## Node Detail View Tap any node to see the full detail view with hardware info, signal metrics, environment sensors, and log navigation: diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md index 7081ae0eb..5b00ea339 100644 --- a/docs/user/telemetry.md +++ b/docs/user/telemetry.md @@ -13,6 +13,7 @@ Meshtastic nodes can report sensor data across the mesh, giving you visibility i | Type | Data | |------|------| | Device Metrics | Battery level, battery voltage, channel utilisation, airtime fraction | +| Local Stats | Packets received/transmitted, relayed packets, duplicate packets, bad receives, node counts, noise floor | | Environment | Temperature (Β°C/Β°F), relative humidity (%), barometric pressure (hPa) | | Air Quality | PM1.0, PM2.5, PM10 particulate counts (Β΅g/mΒ³) | | Power | Voltage and current readings from power monitoring sensors | @@ -27,6 +28,12 @@ Meshtastic nodes can report sensor data across the mesh, giving you visibility i | ![Battery unknown](../assets/screenshots/batteryNil.png) | Unknown | Battery level not reported by this node. | | ![Battery plugged in](../assets/screenshots/batteryPluggedIn.png) | Plugged In | Node is powered via USB/external power. | +### Local Stats + +Local Stats are radio diagnostics reported by the node itself. They help diagnose mesh traffic and receiver conditions with counters for received packets, transmitted packets, relayed packets, duplicate packets, bad receives, canceled packets, online nodes, total nodes, and noise floor. + +Noise floor readings are shown in dBm when available. They can change quickly and should be interpreted with context: antenna direction, nearby interference, and external filters can all affect the displayed value. + ### Air Quality ![IAQ Scale](../assets/screenshots/iaqScale.png)