From f5afce2d0f5c63dbfce10de7dd593adbaac54beb Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Thu, 2 Apr 2026 11:15:02 -0700 Subject: [PATCH 01/34] Update translations file, bump version --- Localizable.xcstrings | 243 ++++++++++++--------------- Meshtastic.xcodeproj/project.pbxproj | 26 ++- 2 files changed, 124 insertions(+), 145 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 30e6245e..8a7d3aeb 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -2,7 +2,6 @@ "sourceLanguage" : "en", "strings" : { "" : { - "shouldTranslate" : false, "localizations" : { "da" : { "stringUnit" : { @@ -10,7 +9,8 @@ "value" : "" } } - } + }, + "shouldTranslate" : false }, "\t%@" : { "localizations" : { @@ -225,95 +225,83 @@ }, "shouldTranslate" : false }, - " : %@" : { + ": %@" : { "localizations" : { - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : " : %@" - } - }, "es" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } }, "sr" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } }, "zh-Hant-TW" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } } }, "shouldTranslate" : false }, - " : %d" : { + ": %d" : { "localizations" : { - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : " : %d" - } - }, "es" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } }, "sr" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } }, "zh-Hant-TW" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } } }, @@ -3018,7 +3006,9 @@ } } }, - "A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients." : {}, + "A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients." : { + + }, "A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key." : { "localizations" : { "es" : { @@ -3863,7 +3853,9 @@ } } }, - "Add CA" : {}, + "Add CA" : { + + }, "Add Channel" : { "localizations" : { "da" : { @@ -11484,8 +11476,12 @@ } } }, - "Client CA Certificate" : {}, - "Client Configuration" : {}, + "Client CA Certificate" : { + + }, + "Client Configuration" : { + + }, "Client Hidden" : { "extractionState" : "stale", "localizations" : { @@ -12186,7 +12182,9 @@ } } }, - "Configuration" : {}, + "Configuration" : { + + }, "Configuration for: %@" : { "localizations" : { "da" : { @@ -14570,7 +14568,9 @@ } } }, - "Delete All" : {}, + "Delete All" : { + + }, "Delete all config, keys and BLE bonds? " : { "localizations" : { "es" : { @@ -18174,7 +18174,9 @@ } } }, - "Download TAK Server Data Package" : {}, + "Download TAK Server Data Package" : { + + }, "Drag & Drop Firmware Update" : { "localizations" : { "da" : { @@ -18961,7 +18963,9 @@ } } }, - "Enable TAK Server" : {}, + "Enable TAK Server" : { + + }, "Enable this device as a Store and Forward server. Requires an ESP32 device with PSRAM." : { "localizations" : { "da" : { @@ -19728,8 +19732,12 @@ } } }, - "Enter P12 Password" : {}, - "Enter the password for the PKCS#12 file" : {}, + "Enter P12 Password" : { + + }, + "Enter the password for the PKCS#12 file" : { + + }, "environment" : { "extractionState" : "stale", "localizations" : { @@ -23771,7 +23779,9 @@ } } }, - "Generate a data package (.zip) to configure TAK clients to connect to this server." : {}, + "Generate a data package (.zip) to configure TAK clients to connect to this server." : { + + }, "Generate a new private key to replace the one currently in use. The public key will automatically be regenerated from your private key." : { "localizations" : { "es" : { @@ -27266,10 +27276,18 @@ } } }, - "Import" : {}, - "Import .pem" : {}, - "Import Custom .p12" : {}, - "Import Error" : {}, + "Import" : { + + }, + "Import .pem" : { + + }, + "Import Custom .p12" : { + + }, + "Import Error" : { + + }, "Import Route" : { "localizations" : { "da" : { @@ -32997,7 +33015,9 @@ } } }, - "mTLS" : {}, + "mTLS" : { + + }, "Multiplier" : { "localizations" : { "da" : { @@ -39159,7 +39179,9 @@ } } }, - "Port" : {}, + "Port" : { + + }, "Position" : { "localizations" : { "da" : { @@ -42816,7 +42838,9 @@ } } }, - "Reload Bundled Certificates" : {}, + "Reload Bundled Certificates" : { + + }, "Remote administration for: %@" : { "localizations" : { "da" : { @@ -43623,7 +43647,9 @@ } } }, - "Reset to Default" : {}, + "Reset to Default" : { + + }, "Restart" : { "localizations" : { "da" : { @@ -43676,7 +43702,9 @@ } } }, - "Restart Server" : {}, + "Restart Server" : { + + }, "Restart to the node you are connected to" : { "localizations" : { "da" : { @@ -46448,8 +46476,6 @@ } } }, - "Secure mTLS connection on port 8089. Both server and client certificates are required." : {}, - "Secure mTLS connection on port 8089. Both server and client certificates are required. TAK Channel Index selects the channel index where TAK messages will be sent." : { "comment" : "A footer for the TAK Server configuration section.", "isCommentAutoGenerated" : true @@ -49143,7 +49169,9 @@ } } }, - "Server Certificate" : {}, + "Server Certificate" : { + + }, "Server Option" : { "localizations" : { "da" : { @@ -49190,7 +49218,9 @@ } } }, - "Server Status" : {}, + "Server Status" : { + + }, "Set" : { "localizations" : { "da" : { @@ -49237,6 +49267,10 @@ } } }, + "Set a channel name" : { + "comment" : "A label for a button that sets a channel name.", + "isCommentAutoGenerated" : true + }, "Set LoRa Region" : { "localizations" : { "da" : { @@ -49856,6 +49890,10 @@ } } }, + "Share with TAK Buddies" : { + "comment" : "A button that shares the QR code with TAK buddies.", + "isCommentAutoGenerated" : true + }, "Share your location in real-time and keep your group coordinated with integrated GPS features." : { "localizations" : { "de" : { @@ -52018,7 +52056,9 @@ } } }, - "Status" : {}, + "Status" : { + + }, "Stay Connected Anywhere" : { "localizations" : { "de" : { @@ -52656,9 +52696,18 @@ } } } + }, + "TAK Cannot Be Used on Public Channel" : { + "comment" : "A warning displayed when the user's primary channel is public.", + "isCommentAutoGenerated" : true + }, + "TAK Channel Index" : { + "comment" : "A label for the TAK channel index.", + "isCommentAutoGenerated" : true + }, + "TAK Server" : { }, - "TAK Server" : {}, "TAK Tracker" : { "extractionState" : "stale", "localizations" : { @@ -55989,7 +56038,9 @@ } } }, - "TLS Certificates" : {}, + "TLS Certificates" : { + + }, "TLS Enabled" : { "localizations" : { "da" : { @@ -62889,88 +62940,6 @@ } } } - }, - ": %@" : { - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - } - }, - "shouldTranslate" : false - }, - ": %d" : { - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - } - }, - "shouldTranslate" : false } }, "version" : "1.1" diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index e3504190..ac3d99cf 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -82,7 +82,6 @@ 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; }; 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; }; 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; }; - AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; 2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01028778B8BFD81F7A039593 /* TAKConnection.swift */; }; 300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */; }; 3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; }; @@ -103,6 +102,7 @@ 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; }; 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; }; A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; }; + AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; }; ABB99DEB2E2EA1C500CFBD05 /* AppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */; }; B16C760DB291CFAB5335EADB /* TAKCertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */; }; @@ -412,7 +412,6 @@ 25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; - AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = ""; }; 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = ""; }; 3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = ""; }; 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; }; @@ -434,6 +433,7 @@ 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetrics.swift; sourceTree = ""; }; 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetricsLog.swift; sourceTree = ""; }; 9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKDataPackageGenerator.swift; sourceTree = ""; }; + AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = ""; }; ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconButton.swift; sourceTree = ""; }; ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPicker.swift; sourceTree = ""; }; B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = ""; }; @@ -1988,6 +1988,9 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -2010,6 +2013,9 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2174,7 +2180,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.9; + MARKETING_VERSION = 2.7.10; OTHER_LDFLAGS = ( "-weak_framework", SwiftUI, @@ -2213,7 +2219,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.9; + MARKETING_VERSION = 2.7.10; OTHER_LDFLAGS = ( "-weak_framework", SwiftUI, @@ -2249,12 +2255,14 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.9; + MARKETING_VERSION = 2.7.10; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; - SUPPORTS_MACCATALYST = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2282,12 +2290,14 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.9; + MARKETING_VERSION = 2.7.10; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; - SUPPORTS_MACCATALYST = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; From 894e9382d8dfc5e8b981a931ad1441f0cf67dd7b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:02:32 -0700 Subject: [PATCH 02/34] Add missing SwiftUI #Preview blocks across 65 views (#1649) * Add SwiftUI previews for simple helper views Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/a2a43e8c-24fd-443a-8a98-13b678770edd Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Add previews for action buttons, ChannelForm, MetricsColumnDetail, and DeviceOnboarding Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/a2a43e8c-24fd-443a-8a98-13b678770edd Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Add previews for config views, log views, AppLog, Firmware, AppData, and UserConfig Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/a2a43e8c-24fd-443a-8a98-13b678770edd Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Add preview for PositionConfig Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/a2a43e8c-24fd-443a-8a98-13b678770edd Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix formatting bugs in #Preview blocks: restore missing .environmentObject/.environment modifiers and add proper tab indentation Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/7eeb7a54-7928-466f-8e39-b00d0012a09d Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Linting fixes --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen --- .../CoreData/MessageEntityExtension.swift | 3 +-- Meshtastic/Helpers/MeshPackets.swift | 1 - .../Helpers/Mqtt/MqttClientProxyManager.swift | 1 - Meshtastic/Helpers/TAK/CoTXMLParser.swift | 8 ++------ Meshtastic/Helpers/TAK/TAKConnection.swift | 1 - Meshtastic/Views/Connect/Connect.swift | 9 ++++----- Meshtastic/Views/Connect/InvalidVersion.swift | 4 ++++ .../Helpers/BLESignalStrengthIndicator.swift | 8 ++++++++ Meshtastic/Views/Helpers/BatteryCompact.swift | 12 +++++++++++ Meshtastic/Views/Helpers/ChannelLock.swift | 12 +++++++++++ Meshtastic/Views/Helpers/CompassView.swift | 20 ++++--------------- Meshtastic/Views/Helpers/DateTimeText.swift | 8 ++++++++ Meshtastic/Views/Helpers/MeshtasticLogo.swift | 5 +++++ .../Helpers/Messages/MessageTemplate.swift | 12 +++++++++++ Meshtastic/Views/Helpers/PowerMetrics.swift | 12 +++++++++++ .../Views/Helpers/RXTXIndicatorView.swift | 9 +++++++++ .../Views/Helpers/RateLimitedButton.swift | 10 ++++++++++ Meshtastic/Views/Helpers/SecureInput.swift | 8 ++++++++ .../Weather/LocalWeatherConditions.swift | 4 ++++ Meshtastic/Views/Layouts/TraceRoute.swift | 7 +++++++ .../Messages/MessageContextMenuItems.swift | 2 -- .../Views/Messages/TapbackInputView.swift | 7 ++++++- .../Views/Nodes/DetectionSensorLog.swift | 13 ++++++++++++ Meshtastic/Views/Nodes/DeviceMetricsLog.swift | 13 ++++++++++++ .../Views/Nodes/EnvironmentMetricsLog.swift | 13 ++++++++++++ .../Helpers/Actions/ClientHistoryButton.swift | 10 ++++++++++ .../Helpers/Actions/DeleteNodeButton.swift | 11 ++++++++++ .../Actions/ExchangePositionsButton.swift | 10 ++++++++++ .../Actions/ExchangeUserInfoButton.swift | 10 ++++++++++ .../Helpers/Actions/FavoriteNodeButton.swift | 13 ++++++++++++ .../Helpers/Actions/IgnoreNodeButton.swift | 9 +++++++++ .../Helpers/Actions/NavigateToButton.swift | 12 +++++++++++ .../Helpers/Actions/NodeAlertsButton.swift | 11 ++++++++++ .../Helpers/Actions/TraceRouteButton.swift | 12 +++++++++++ .../Map/MapContent/AnimatedNodePin.swift | 8 ++++++++ .../Nodes/Helpers/Map/MapSettingsForm.swift | 10 ++++++++++ .../Metrics Columns/MetricsColumnDetail.swift | 7 +++++++ .../Views/Nodes/Helpers/NodeListFilter.swift | 4 ++++ Meshtastic/Views/Nodes/PaxCounterLog.swift | 13 ++++++++++++ Meshtastic/Views/Nodes/PositionLog.swift | 13 ++++++++++++ Meshtastic/Views/Nodes/PowerMetricsLog.swift | 13 ++++++++++++ Meshtastic/Views/Nodes/TraceRouteLog.swift | 13 ++++++++++++ .../Views/Onboarding/DeviceOnboarding.swift | 5 +++++ Meshtastic/Views/Settings/About.swift | 6 ++++++ Meshtastic/Views/Settings/AppData.swift | 7 +++++++ Meshtastic/Views/Settings/AppLog.swift | 4 ++++ .../Views/Settings/Channels/ChannelForm.swift | 18 +++++++++++++++++ .../Settings/Config/BluetoothConfig.swift | 7 +++++++ .../Views/Settings/Config/ConfigHeader.swift | 10 ++++++++++ .../Views/Settings/Config/DeviceConfig.swift | 7 +++++++ .../Views/Settings/Config/DisplayConfig.swift | 7 +++++++ .../Views/Settings/Config/LoRaConfig.swift | 7 +++++++ .../Config/Module/AmbientLightingConfig.swift | 7 +++++++ .../Config/Module/CannedMessagesConfig.swift | 7 +++++++ .../Config/Module/DetectionSensorConfig.swift | 7 +++++++ .../Module/ExternalNotificationConfig.swift | 6 ++++++ .../Settings/Config/Module/MQTTConfig.swift | 7 +++++++ .../Config/Module/PaxCounterConfig.swift | 7 +++++++ .../Config/Module/RangeTestConfig.swift | 7 +++++++ .../Settings/Config/Module/RtttlConfig.swift | 7 +++++++ .../Settings/Config/Module/SerialConfig.swift | 7 +++++++ .../Config/Module/StoreForwardConfig.swift | 7 +++++++ .../Config/Module/TelemetryConfig.swift | 7 +++++++ .../Views/Settings/Config/NetworkConfig.swift | 7 +++++++ .../Settings/Config/PositionConfig.swift | 7 +++++++ .../Views/Settings/Config/PowerConfig.swift | 7 +++++++ .../Settings/Config/SaveConfigButton.swift | 5 +++++ .../Settings/Config/SecurityConfig.swift | 7 +++++++ Meshtastic/Views/Settings/Firmware.swift | 7 +++++++ Meshtastic/Views/Settings/GPSStatus.swift | 4 ++++ .../Views/Settings/Logs/AppLogFilter.swift | 4 ++++ .../Views/Settings/TAKServerConfig.swift | 2 -- .../Views/Settings/UpdateIntervalPicker.swift | 8 ++++++++ Meshtastic/Views/Settings/UserConfig.swift | 7 +++++++ MeshtasticTests/ConnectViewTests.swift | 6 +++--- MeshtasticTests/RouterTests.swift | 2 +- 76 files changed, 567 insertions(+), 41 deletions(-) diff --git a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift index d6d2c997..c9fab38a 100644 --- a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift @@ -59,8 +59,7 @@ extension MessageEntity { let users = try context.fetch(request) // If exactly one match is found, return its name - if users.count == 1, let name = users.first?.longName, !name.isEmpty - { + if users.count == 1, let name = users.first?.longName, !name.isEmpty { return "\(name)" } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 54e1661f..f6b8c485 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -1278,4 +1278,3 @@ actor MeshPackets { } } } - diff --git a/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift b/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift index 936f23ad..e0cd2473 100644 --- a/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift +++ b/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift @@ -184,4 +184,3 @@ extension MqttClientProxyManager: CocoaMQTTDelegate { Logger.mqtt.debug("πŸ“² [MQTT Client Proxy] pong") } } - diff --git a/Meshtastic/Helpers/TAK/CoTXMLParser.swift b/Meshtastic/Helpers/TAK/CoTXMLParser.swift index 7f9325e2..eada8793 100644 --- a/Meshtastic/Helpers/TAK/CoTXMLParser.swift +++ b/Meshtastic/Helpers/TAK/CoTXMLParser.swift @@ -71,10 +71,7 @@ final class CoTXMLParser: NSObject, XMLParserDelegate { } // MARK: - XMLParserDelegate - - func parser(_ parser: XMLParser, didStartElement elementName: String, - namespaceURI: String?, qualifiedName qName: String?, - attributes attributeDict: [String: String] = [:]) { + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { elementStack.append(elementName) currentElement = elementName currentText = "" @@ -138,8 +135,7 @@ final class CoTXMLParser: NSObject, XMLParserDelegate { } } - func parser(_ parser: XMLParser, didEndElement elementName: String, - namespaceURI: String?, qualifiedName qName: String?) { + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { if elementName == "remarks" { remarksText = currentText.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/Meshtastic/Helpers/TAK/TAKConnection.swift b/Meshtastic/Helpers/TAK/TAKConnection.swift index b4678f06..57340154 100644 --- a/Meshtastic/Helpers/TAK/TAKConnection.swift +++ b/Meshtastic/Helpers/TAK/TAKConnection.swift @@ -494,4 +494,3 @@ enum TAKConnectionError: LocalizedError { } } } - diff --git a/Meshtastic/Views/Connect/Connect.swift b/Meshtastic/Views/Connect/Connect.swift index b66b1b59..84e90c6e 100644 --- a/Meshtastic/Views/Connect/Connect.swift +++ b/Meshtastic/Views/Connect/Connect.swift @@ -565,18 +565,18 @@ struct DeviceConnectRow: View { } // Show transport type #if !targetEnvironment(macCatalyst) - HStack(alignment: .center){ + HStack(alignment: .center) { TransportIcon(transportType: device.transportType) if device.isManualConnection && (device.longName != nil || device.shortName != nil) { - VStack (alignment: .leading) { + VStack(alignment: .leading) { Text("Last seen device:") Text("\(String(describing: device))") } } }.padding(.top, 3.0) #else - //Different alignment for Mac - HStack(alignment: .firstTextBaseline){ + // Different alignment for Mac + HStack(alignment: .firstTextBaseline) { TransportIcon(transportType: device.transportType) if device.isManualConnection && (device.longName != nil || device.shortName != nil) { Text("Last seen device: \(String(describing: device))") @@ -609,4 +609,3 @@ struct DeviceConnectRow: View { } } } - diff --git a/Meshtastic/Views/Connect/InvalidVersion.swift b/Meshtastic/Views/Connect/InvalidVersion.swift index 5d475756..d6030139 100644 --- a/Meshtastic/Views/Connect/InvalidVersion.swift +++ b/Meshtastic/Views/Connect/InvalidVersion.swift @@ -62,3 +62,7 @@ struct InvalidVersion: View { } } } + +#Preview { + InvalidVersion(minimumVersion: "2.5.4", version: "2.3.0") +} diff --git a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift index a68b3597..7e83940f 100644 --- a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift +++ b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift @@ -94,3 +94,11 @@ enum BLESignalStrength: Int { case normal = 1 case strong = 2 } + +#Preview { + HStack(spacing: 16) { + SignalStrengthIndicator(signalStrength: .weak) + SignalStrengthIndicator(signalStrength: .normal) + SignalStrengthIndicator(signalStrength: .strong) + } +} diff --git a/Meshtastic/Views/Helpers/BatteryCompact.swift b/Meshtastic/Views/Helpers/BatteryCompact.swift index 60c1307a..aed8472b 100644 --- a/Meshtastic/Views/Helpers/BatteryCompact.swift +++ b/Meshtastic/Views/Helpers/BatteryCompact.swift @@ -111,3 +111,15 @@ struct BatteryCompact: View { } ?? "Unknown") } } + +#Preview { + VStack(spacing: 12) { + BatteryCompact(batteryLevel: 75, font: .caption, iconFont: .caption, color: .gray) + BatteryCompact(batteryLevel: 50, font: .caption, iconFont: .caption, color: .gray) + BatteryCompact(batteryLevel: 25, font: .caption, iconFont: .caption, color: .gray) + BatteryCompact(batteryLevel: 10, font: .caption, iconFont: .caption, color: .gray) + BatteryCompact(batteryLevel: 100, font: .caption, iconFont: .caption, color: .gray) + BatteryCompact(batteryLevel: 101, font: .caption, iconFont: .caption, color: .gray) + BatteryCompact(batteryLevel: nil, font: .caption, iconFont: .caption, color: .gray) + } +} diff --git a/Meshtastic/Views/Helpers/ChannelLock.swift b/Meshtastic/Views/Helpers/ChannelLock.swift index 2621311a..facc07cb 100644 --- a/Meshtastic/Views/Helpers/ChannelLock.swift +++ b/Meshtastic/Views/Helpers/ChannelLock.swift @@ -32,3 +32,15 @@ struct ChannelLock: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let encryptedChannel = ChannelEntity(context: context) + encryptedChannel.psk = Data([0x01, 0x02, 0x03, 0x04]) + let unencryptedChannel = ChannelEntity(context: context) + unencryptedChannel.psk = Data() + return HStack(spacing: 16) { + ChannelLock(channel: encryptedChannel) + ChannelLock(channel: unencryptedChannel) + } +} diff --git a/Meshtastic/Views/Helpers/CompassView.swift b/Meshtastic/Views/Helpers/CompassView.swift index 1e58b224..c7185acf 100644 --- a/Meshtastic/Views/Helpers/CompassView.swift +++ b/Meshtastic/Views/Helpers/CompassView.swift @@ -38,7 +38,7 @@ struct CompassView: View { } // Trigger a vibration if aligned with waypoint - private func checkAlignment(bearing: Double,heading: Double) { + private func checkAlignment(bearing: Double, heading: Double) { // Compute minimal angular difference between heading and bearing in [0, 180] let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360) let diff = min(rawDiff, 360 - rawDiff) @@ -53,7 +53,6 @@ struct CompassView: View { inAlignment = false } } - private func distanceToWaypoint() -> CLLocationDistance? { guard @@ -76,7 +75,6 @@ struct CompassView: View { return formatter.string(from: measurement) } - var body: some View { NavigationStack { VStack(spacing: 15) { @@ -88,14 +86,14 @@ struct CompassView: View { .foregroundColor(color) if let wp = waypointLocation { - HStack{ + HStack { Image(systemName: "mappin.and.ellipse") Text("\(String(format: "%.4f", wp.latitude)), \(String(format: "%.4f", wp.longitude))") .font(.subheadline) } if let distance = distanceToWaypoint() { - HStack{ + HStack { Image(systemName: "lines.measurement.horizontal") Text("Distance: \(formatDistance(distance))") .font(.subheadline) @@ -137,7 +135,7 @@ struct CompassView: View { ) // Move waypoint marker outside compass .onChange(of: locationsHandler.heading) { _, _ in - checkAlignment(bearing: bearing,heading:locationsHandler.heading) + checkAlignment(bearing: bearing, heading:locationsHandler.heading) } } @@ -159,9 +157,7 @@ struct CompassView: View { } } - // MARK: - Waypoint Marker View - struct WaypointMarkerView: View { let bearing: Double let compassDegrees: Double @@ -177,9 +173,7 @@ struct WaypointMarkerView: View { } - // MARK: - Bearing Calculator - struct BearingCalculator { static func bearingBetween( @@ -205,9 +199,7 @@ struct BearingCalculator { } } - // MARK: - Marker Model - struct Marker: Hashable { let degrees: Double let label: String @@ -239,9 +231,7 @@ struct Marker: Hashable { } } - // MARK: - Compass Marker View - struct CompassMarkerView: View { let marker: Marker let compassDegrees: Double @@ -281,9 +271,7 @@ struct CompassMarkerView: View { } } - // MARK: - Preview - struct CompassView_Previews: PreviewProvider { static var previews: some View { CompassView( diff --git a/Meshtastic/Views/Helpers/DateTimeText.swift b/Meshtastic/Views/Helpers/DateTimeText.swift index 38e386f3..34ffcdcf 100644 --- a/Meshtastic/Views/Helpers/DateTimeText.swift +++ b/Meshtastic/Views/Helpers/DateTimeText.swift @@ -28,3 +28,11 @@ struct DateTimeText: View { } } } + +#Preview { + VStack { + DateTimeText(dateTime: Date()) + DateTimeText(dateTime: Calendar.current.date(byAdding: .day, value: -1, to: Date())) + DateTimeText(dateTime: nil) + } +} diff --git a/Meshtastic/Views/Helpers/MeshtasticLogo.swift b/Meshtastic/Views/Helpers/MeshtasticLogo.swift index 6c717140..099728c9 100644 --- a/Meshtastic/Views/Helpers/MeshtasticLogo.swift +++ b/Meshtastic/Views/Helpers/MeshtasticLogo.swift @@ -51,3 +51,8 @@ struct MeshtasticLogo: View { #endif } } + +#Preview { + MeshtasticLogo() + .frame(width: 200, height: 44) +} diff --git a/Meshtastic/Views/Helpers/Messages/MessageTemplate.swift b/Meshtastic/Views/Helpers/Messages/MessageTemplate.swift index e604b564..2173c5c5 100644 --- a/Meshtastic/Views/Helpers/Messages/MessageTemplate.swift +++ b/Meshtastic/Views/Helpers/Messages/MessageTemplate.swift @@ -36,3 +36,15 @@ struct MessageTemplate: View { } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let user = UserEntity(context: context) + user.longName = "Test User" + user.shortName = "TU" + let message = MessageEntity(context: context) + message.messagePayload = "Hello, World!" + message.messageTimestamp = Int32(Date().timeIntervalSince1970) + message.replyID = 0 + return MessageTemplate(user: user, message: message) +} diff --git a/Meshtastic/Views/Helpers/PowerMetrics.swift b/Meshtastic/Views/Helpers/PowerMetrics.swift index e85c0f6a..6009e5a6 100644 --- a/Meshtastic/Views/Helpers/PowerMetrics.swift +++ b/Meshtastic/Views/Helpers/PowerMetrics.swift @@ -94,3 +94,15 @@ struct PowerMetricCompactWidget: View { .background(.tertiary, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) } } + +#Preview { + let gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 10), count: 2) + Form { + LazyVGrid(columns: gridItemLayout) { + PowerMetricCompactWidget(type: .voltage, value: 3.72, title: "Channel 1 Voltage") + PowerMetricCompactWidget(type: .current, value: 125.3, title: "Channel 1 Current") + PowerMetricCompactWidget(type: .voltage, value: 5.01, title: "Channel 2 Voltage") + PowerMetricCompactWidget(type: .current, value: 42.7, title: "Channel 2 Current") + } + } +} diff --git a/Meshtastic/Views/Helpers/RXTXIndicatorView.swift b/Meshtastic/Views/Helpers/RXTXIndicatorView.swift index 860b3734..f31ae179 100644 --- a/Meshtastic/Views/Helpers/RXTXIndicatorView.swift +++ b/Meshtastic/Views/Helpers/RXTXIndicatorView.swift @@ -119,3 +119,12 @@ struct LEDIndicator: View { } } } + +#Preview { + HStack(spacing: 12) { + LEDIndicator(flash: .constant(1), color: .green) + .frame(width: 10, height: 10) + LEDIndicator(flash: .constant(0), color: .red) + .frame(width: 10, height: 10) + } +} diff --git a/Meshtastic/Views/Helpers/RateLimitedButton.swift b/Meshtastic/Views/Helpers/RateLimitedButton.swift index 30c5d667..894bd526 100644 --- a/Meshtastic/Views/Helpers/RateLimitedButton.swift +++ b/Meshtastic/Views/Helpers/RateLimitedButton.swift @@ -45,6 +45,16 @@ public struct RateLimitedButton: View { } } +#Preview { + RateLimitedButton(key: "preview", rateLimit: 30, action: { }) { rateLimitInfo in + if let info = rateLimitInfo { + Label("\(Int(info.secondsRemaining))s", systemImage: "clock") + } else { + Label("Send", systemImage: "paperplane") + } + } +} + // To store the time an action occured (name by a key) and the time limit // Does not persist across app launches class RateLimitStorage: ObservableObject { diff --git a/Meshtastic/Views/Helpers/SecureInput.swift b/Meshtastic/Views/Helpers/SecureInput.swift index 687cc6fe..bbffe662 100644 --- a/Meshtastic/Views/Helpers/SecureInput.swift +++ b/Meshtastic/Views/Helpers/SecureInput.swift @@ -69,3 +69,11 @@ struct SecureInput: View { } } } + +#Preview { + List { + SecureInput("Password", text: .constant("s3cretP@ss"), isValid: .constant(true)) + SecureInput("Invalid Key", text: .constant("short"), isValid: .constant(false)) + SecureInput("Empty", text: .constant(""), isValid: .constant(true)) + } +} diff --git a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift index def3ae6c..967c2c2b 100644 --- a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift +++ b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift @@ -118,3 +118,7 @@ func calculateDewPoint(temp: Float, relativeHumidity: Float, convertToLocale: Bo } return dewPointUnit.converted(to: format).value } + +#Preview { + LocalWeatherConditions(location: CLLocation(latitude: 47.6062, longitude: -122.3321)) +} diff --git a/Meshtastic/Views/Layouts/TraceRoute.swift b/Meshtastic/Views/Layouts/TraceRoute.swift index fd4d7d92..f8f8e8d0 100644 --- a/Meshtastic/Views/Layouts/TraceRoute.swift +++ b/Meshtastic/Views/Layouts/TraceRoute.swift @@ -22,6 +22,13 @@ struct TraceRouteComponent: View { } } +#Preview { + TraceRouteComponent { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.title) + } +} + struct TraceRoute: Layout { var animatableData: AnimatablePair { get { diff --git a/Meshtastic/Views/Messages/MessageContextMenuItems.swift b/Meshtastic/Views/Messages/MessageContextMenuItems.swift index 0d8843ef..8c5a301b 100644 --- a/Meshtastic/Views/Messages/MessageContextMenuItems.swift +++ b/Meshtastic/Views/Messages/MessageContextMenuItems.swift @@ -56,8 +56,6 @@ struct MessageContextMenuItems: View { let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) // Compute a relay display string if relayNode is present - - VStack { Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))") .foregroundColor(.gray) diff --git a/Meshtastic/Views/Messages/TapbackInputView.swift b/Meshtastic/Views/Messages/TapbackInputView.swift index 36a1e9b0..03c6597c 100644 --- a/Meshtastic/Views/Messages/TapbackInputView.swift +++ b/Meshtastic/Views/Messages/TapbackInputView.swift @@ -79,6 +79,12 @@ struct TapbackInputView: View { } } +#Preview { + TapbackInputView(text: .constant(""), isPresented: .constant(true)) { emoji in + print("Selected: \(emoji)") + } +} + extension UIView { var firstResponder: UIView? { guard !isFirstResponder else { return self } @@ -90,4 +96,3 @@ extension UIView { return nil } } - diff --git a/Meshtastic/Views/Nodes/DetectionSensorLog.swift b/Meshtastic/Views/Nodes/DetectionSensorLog.swift index f86b9acb..ae57cab0 100644 --- a/Meshtastic/Views/Nodes/DetectionSensorLog.swift +++ b/Meshtastic/Views/Nodes/DetectionSensorLog.swift @@ -141,3 +141,16 @@ struct DetectionSensorLog: View { ) } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let user = UserEntity(context: context) + user.longName = "Test Node" + user.shortName = "TN" + node.user = user + return DetectionSensorLog(node: node) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift index 90e8d119..09bca3e7 100644 --- a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift +++ b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift @@ -254,3 +254,16 @@ struct DeviceMetricsLog: View { ) } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let user = UserEntity(context: context) + user.longName = "Test Node" + user.shortName = "TN" + node.user = user + return DeviceMetricsLog(node: node) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index 84148d2e..ca19ebee 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -186,3 +186,16 @@ struct EnvironmentMetricsLog: View { return lower...upper } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let user = UserEntity(context: context) + user.longName = "Test Node" + user.shortName = "TN" + node.user = user + return EnvironmentMetricsLog(node: node) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/ClientHistoryButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/ClientHistoryButton.swift index 624ca183..020855e7 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/ClientHistoryButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/ClientHistoryButton.swift @@ -40,3 +40,13 @@ struct ClientHistoryButton: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let connectedNode = NodeInfoEntity(context: context) + connectedNode.num = 987654321 + return ClientHistoryButton(connectedNode: connectedNode, node: node) + .environmentObject(AccessoryManager.shared) +} diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/DeleteNodeButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/DeleteNodeButton.swift index 70ffc217..a7b096b3 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/DeleteNodeButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/DeleteNodeButton.swift @@ -64,3 +64,14 @@ struct DeleteNodeButton: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let connectedNode = NodeInfoEntity(context: context) + connectedNode.num = 987654321 + let node = NodeInfoEntity(context: context) + node.num = 123456789 + return DeleteNodeButton(connectedNode: connectedNode, node: node) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/ExchangePositionsButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/ExchangePositionsButton.swift index f303f21e..c71e6b87 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/ExchangePositionsButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/ExchangePositionsButton.swift @@ -61,3 +61,13 @@ struct ExchangePositionsButton: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let connectedNode = NodeInfoEntity(context: context) + connectedNode.num = 987654321 + return ExchangePositionsButton(node: node, connectedNode: connectedNode) + .environmentObject(AccessoryManager.shared) +} diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/ExchangeUserInfoButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/ExchangeUserInfoButton.swift index 321b1532..595eec4f 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/ExchangeUserInfoButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/ExchangeUserInfoButton.swift @@ -59,3 +59,13 @@ struct ExchangeUserInfoButton: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let connectedNode = NodeInfoEntity(context: context) + connectedNode.num = 987654321 + return ExchangeUserInfoButton(node: node, connectedNode: connectedNode) + .environmentObject(AccessoryManager.shared) +} diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/FavoriteNodeButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/FavoriteNodeButton.swift index 83bac1d3..d3a54864 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/FavoriteNodeButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/FavoriteNodeButton.swift @@ -79,3 +79,16 @@ struct FavoriteNodeButton: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let user = UserEntity(context: context) + user.longName = "Test Node" + user.shortName = "TN" + node.user = user + return FavoriteNodeButton(node: node) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift index c15c69d1..51a8801b 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift @@ -51,3 +51,12 @@ struct IgnoreNodeButton: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + return IgnoreNodeButton(node: node) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/NavigateToButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/NavigateToButton.swift index 403d1b98..ec130098 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/NavigateToButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/NavigateToButton.swift @@ -54,3 +54,15 @@ struct NavigateToButton: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let user = UserEntity(context: context) + user.longName = "Test Node" + user.shortName = "TN" + user.num = 123456789 + node.user = user + return NavigateToButton(node: node) +} diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/NodeAlertsButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/NodeAlertsButton.swift index 663d2900..179fb1af 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/NodeAlertsButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/NodeAlertsButton.swift @@ -31,3 +31,14 @@ struct NodeAlertsButton: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let user = UserEntity(context: context) + user.longName = "Test Node" + user.shortName = "TN" + node.user = user + return NodeAlertsButton(context: context, node: node, user: user) +} diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift index 11a14460..62e696d4 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift @@ -43,3 +43,15 @@ struct TraceRouteButton: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let user = UserEntity(context: context) + user.longName = "Test Node" + user.shortName = "TN" + node.user = user + return TraceRouteButton(node: node) + .environmentObject(AccessoryManager.shared) +} diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/AnimatedNodePin.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/AnimatedNodePin.swift index 6868f499..245142c9 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/AnimatedNodePin.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/AnimatedNodePin.swift @@ -51,6 +51,14 @@ struct AnimatedNodePin: View, Equatable { } } +#Preview { + VStack(spacing: 20) { + AnimatedNodePin(nodeColor: .systemBlue, shortName: "TN", hasDetectionSensorMetrics: false, isOnline: true, calculatedDelay: 0.0) + AnimatedNodePin(nodeColor: .systemGreen, shortName: "AB", hasDetectionSensorMetrics: true, isOnline: true, calculatedDelay: 0.2) + AnimatedNodePin(nodeColor: .systemRed, shortName: "XY", hasDetectionSensorMetrics: false, isOnline: false, calculatedDelay: 0.0) + } +} + struct PulsingCircle: View { let nodeColor: UIColor let calculatedDelay: Double diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift index ca17c6fa..b284d49b 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift @@ -228,3 +228,13 @@ struct MapSettingsForm: View { } } + +#Preview { + MapSettingsForm( + traffic: .constant(false), + pointsOfInterest: .constant(true), + mapLayer: .constant(.standard), + meshMap: .constant(true), + enabledOverlayConfigs: .constant(Set()) + ) +} diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift index e456ce31..e2061044 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift @@ -99,3 +99,10 @@ struct MetricsColumnDetail: View { .interactiveDismissDisabled(false) } } + +#Preview { + MetricsColumnDetail( + columnList: MetricsColumnList(columns: []), + seriesList: MetricsSeriesList() + ) +} diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift b/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift index 3236f0d3..9f1c03c1 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift @@ -208,3 +208,7 @@ struct NodeListFilter: View { .presentationBackgroundInteraction(.enabled(upThrough: .large)) } } + +#Preview { + NodeListFilter(filters: NodeFilterParameters()) +} diff --git a/Meshtastic/Views/Nodes/PaxCounterLog.swift b/Meshtastic/Views/Nodes/PaxCounterLog.swift index 482e0d69..c4a93e59 100644 --- a/Meshtastic/Views/Nodes/PaxCounterLog.swift +++ b/Meshtastic/Views/Nodes/PaxCounterLog.swift @@ -224,3 +224,16 @@ struct PaxCounterLog: View { ) } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let user = UserEntity(context: context) + user.longName = "Test Node" + user.shortName = "TN" + node.user = user + return PaxCounterLog(node: node) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Nodes/PositionLog.swift b/Meshtastic/Views/Nodes/PositionLog.swift index af307f20..f667f5ee 100644 --- a/Meshtastic/Views/Nodes/PositionLog.swift +++ b/Meshtastic/Views/Nodes/PositionLog.swift @@ -181,3 +181,16 @@ struct PositionLog: View { }) } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let user = UserEntity(context: context) + user.longName = "Test Node" + user.shortName = "TN" + node.user = user + return PositionLog(node: node) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Nodes/PowerMetricsLog.swift b/Meshtastic/Views/Nodes/PowerMetricsLog.swift index b4578a59..b43b1c47 100644 --- a/Meshtastic/Views/Nodes/PowerMetricsLog.swift +++ b/Meshtastic/Views/Nodes/PowerMetricsLog.swift @@ -297,3 +297,16 @@ struct PowerMetricsLog: View { ) } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let user = UserEntity(context: context) + user.longName = "Test Node" + user.shortName = "TN" + node.user = user + return PowerMetricsLog(node: node) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Nodes/TraceRouteLog.swift b/Meshtastic/Views/Nodes/TraceRouteLog.swift index 3b84aec3..a4c4c91e 100644 --- a/Meshtastic/Views/Nodes/TraceRouteLog.swift +++ b/Meshtastic/Views/Nodes/TraceRouteLog.swift @@ -287,3 +287,16 @@ func getTraceRouteHops(context: NSManagedObjectContext) -> [TraceRouteHopEntity] array.append(trh8) return array } + +#Preview { + let context = PersistenceController.preview.container.viewContext + let node = NodeInfoEntity(context: context) + node.num = 123456789 + let user = UserEntity(context: context) + user.longName = "Test Node" + user.shortName = "TN" + node.user = user + return TraceRouteLog(node: node) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift index d95d6977..f0c9f1f1 100644 --- a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift +++ b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift @@ -454,3 +454,8 @@ struct DeviceOnboarding: View { } } + +#Preview { + DeviceOnboarding() + .environmentObject(AccessoryManager.shared) +} diff --git a/Meshtastic/Views/Settings/About.swift b/Meshtastic/Views/Settings/About.swift index 98355864..9d95c472 100644 --- a/Meshtastic/Views/Settings/About.swift +++ b/Meshtastic/Views/Settings/About.swift @@ -67,3 +67,9 @@ struct AboutMeshtastic: View { .navigationBarTitleDisplayMode(.inline) } } + +#Preview { + NavigationView { + AboutMeshtastic() + } +} diff --git a/Meshtastic/Views/Settings/AppData.swift b/Meshtastic/Views/Settings/AppData.swift index aa7ac625..16034020 100644 --- a/Meshtastic/Views/Settings/AppData.swift +++ b/Meshtastic/Views/Settings/AppData.swift @@ -143,3 +143,10 @@ struct AppData: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return AppData() + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/AppLog.swift b/Meshtastic/Views/Settings/AppLog.swift index 08c09664..77f90440 100644 --- a/Meshtastic/Views/Settings/AppLog.swift +++ b/Meshtastic/Views/Settings/AppLog.swift @@ -271,3 +271,7 @@ extension AppLog { } extension OSLogEntry: @retroactive Identifiable { } + +#Preview { + AppLog() +} diff --git a/Meshtastic/Views/Settings/Channels/ChannelForm.swift b/Meshtastic/Views/Settings/Channels/ChannelForm.swift index 3579b938..78f85344 100644 --- a/Meshtastic/Views/Settings/Channels/ChannelForm.swift +++ b/Meshtastic/Views/Settings/Channels/ChannelForm.swift @@ -250,3 +250,21 @@ struct ChannelForm: View { } } } + +#Preview { + ChannelForm( + channelIndex: .constant(0), + channelName: .constant("LongFast"), + channelKeySize: .constant(32), + channelKey: .constant("AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="), + channelRole: .constant(1), + uplink: .constant(false), + downlink: .constant(false), + positionPrecision: .constant(14), + preciseLocation: .constant(false), + positionsEnabled: .constant(true), + hasChanges: .constant(false), + hasValidKey: .constant(true), + supportedVersion: .constant(true) + ) +} diff --git a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift index a5955caa..b1934df3 100644 --- a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift +++ b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift @@ -147,3 +147,10 @@ struct BluetoothConfig: View { self.hasChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return BluetoothConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/ConfigHeader.swift b/Meshtastic/Views/Settings/Config/ConfigHeader.swift index 1331235d..c8ed4f91 100644 --- a/Meshtastic/Views/Settings/Config/ConfigHeader.swift +++ b/Meshtastic/Views/Settings/Config/ConfigHeader.swift @@ -37,3 +37,13 @@ struct ConfigHeader: View { } } } + +#Preview { + ConfigHeader( + title: "Bluetooth Configuration", + config: \NodeInfoEntity.bluetoothConfig, + node: nil, + onAppear: { } + ) + .environmentObject(AccessoryManager.shared) +} diff --git a/Meshtastic/Views/Settings/Config/DeviceConfig.swift b/Meshtastic/Views/Settings/Config/DeviceConfig.swift index 6bfd711b..7d0814dc 100644 --- a/Meshtastic/Views/Settings/Config/DeviceConfig.swift +++ b/Meshtastic/Views/Settings/Config/DeviceConfig.swift @@ -345,3 +345,10 @@ struct DeviceConfig: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return DeviceConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/DisplayConfig.swift b/Meshtastic/Views/Settings/Config/DisplayConfig.swift index ddc67d0f..a9240da0 100644 --- a/Meshtastic/Views/Settings/Config/DisplayConfig.swift +++ b/Meshtastic/Views/Settings/Config/DisplayConfig.swift @@ -235,3 +235,10 @@ struct DisplayConfig: View { self.hasChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return DisplayConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/LoRaConfig.swift b/Meshtastic/Views/Settings/Config/LoRaConfig.swift index 17e18dc7..4129e133 100644 --- a/Meshtastic/Views/Settings/Config/LoRaConfig.swift +++ b/Meshtastic/Views/Settings/Config/LoRaConfig.swift @@ -321,3 +321,10 @@ struct LoRaConfig: View { self.hasChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return LoRaConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift index 515ca933..db2c98c2 100644 --- a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift @@ -134,3 +134,10 @@ struct AmbientLightingConfig: View { self.hasChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return AmbientLightingConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift index cc0a87eb..54a9a6bd 100644 --- a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift @@ -355,3 +355,10 @@ struct CannedMessagesConfig: View { self.hasMessagesChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return CannedMessagesConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift index 02afcc3a..a56d42b1 100644 --- a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift @@ -261,3 +261,10 @@ struct DetectionSensorConfig: View { self.hasChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return DetectionSensorConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift index 23f4ef40..b3694ee3 100644 --- a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift @@ -283,3 +283,9 @@ struct ExternalNotificationConfig: View { } } +#Preview { + let context = PersistenceController.preview.container.viewContext + return ExternalNotificationConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index 5336c5ef..a3417650 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -464,3 +464,10 @@ struct MQTTConfig: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return MQTTConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift index edc46ed0..90b83f73 100644 --- a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift @@ -123,3 +123,10 @@ struct PaxCounterConfig: View { paxcounterUpdateInterval = UpdateInterval(from: Int(node?.paxCounterConfig?.updateInterval ?? 1800)) } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return PaxCounterConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift index ed65ce4e..74386285 100644 --- a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift @@ -143,3 +143,10 @@ struct RangeTestConfig: View { self.hasChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return RangeTestConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift index d052bf7e..6f45103a 100644 --- a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift @@ -114,3 +114,10 @@ struct RtttlConfig: View { self.hasChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return RtttlConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift index a773190d..ff070c09 100644 --- a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift @@ -205,3 +205,10 @@ struct SerialConfig: View { self.hasChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return SerialConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift index bc35258d..577ef4b5 100644 --- a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift @@ -197,3 +197,10 @@ struct StoreForwardConfig: View { self.hasChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return StoreForwardConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift index 44a38cbd..caae19c3 100644 --- a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift @@ -241,3 +241,10 @@ struct TelemetryConfig: View { self.hasChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return TelemetryConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/NetworkConfig.swift b/Meshtastic/Views/Settings/Config/NetworkConfig.swift index 92c5cc5e..044e2a2f 100644 --- a/Meshtastic/Views/Settings/Config/NetworkConfig.swift +++ b/Meshtastic/Views/Settings/Config/NetworkConfig.swift @@ -209,3 +209,10 @@ struct NetworkConfig: View { self.hasChanges = false } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return NetworkConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/PositionConfig.swift b/Meshtastic/Views/Settings/Config/PositionConfig.swift index 3b7f65ec..68c8e9bd 100644 --- a/Meshtastic/Views/Settings/Config/PositionConfig.swift +++ b/Meshtastic/Views/Settings/Config/PositionConfig.swift @@ -562,3 +562,10 @@ struct PositionConfig: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return PositionConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/PowerConfig.swift b/Meshtastic/Views/Settings/Config/PowerConfig.swift index 2ad978b5..737bd7f7 100644 --- a/Meshtastic/Views/Settings/Config/PowerConfig.swift +++ b/Meshtastic/Views/Settings/Config/PowerConfig.swift @@ -226,3 +226,10 @@ private struct FloatField: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return PowerConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Config/SaveConfigButton.swift b/Meshtastic/Views/Settings/Config/SaveConfigButton.swift index 43fcb66a..e21f6a1b 100644 --- a/Meshtastic/Views/Settings/Config/SaveConfigButton.swift +++ b/Meshtastic/Views/Settings/Config/SaveConfigButton.swift @@ -58,3 +58,8 @@ struct SaveConfigButton: View { } } } + +#Preview { + SaveConfigButton(node: nil, hasChanges: .constant(true), onConfirmation: { }) + .environmentObject(AccessoryManager.shared) +} diff --git a/Meshtastic/Views/Settings/Config/SecurityConfig.swift b/Meshtastic/Views/Settings/Config/SecurityConfig.swift index c6a6a6d3..d8c2a969 100644 --- a/Meshtastic/Views/Settings/Config/SecurityConfig.swift +++ b/Meshtastic/Views/Settings/Config/SecurityConfig.swift @@ -428,3 +428,10 @@ struct SecurityConfig: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return SecurityConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Firmware.swift b/Meshtastic/Views/Settings/Firmware.swift index 490f7e01..3869d145 100644 --- a/Meshtastic/Views/Settings/Firmware.swift +++ b/Meshtastic/Views/Settings/Firmware.swift @@ -206,3 +206,10 @@ struct Firmware: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return Firmware(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/GPSStatus.swift b/Meshtastic/Views/Settings/GPSStatus.swift index c92a647c..f138f980 100644 --- a/Meshtastic/Views/Settings/GPSStatus.swift +++ b/Meshtastic/Views/Settings/GPSStatus.swift @@ -65,3 +65,7 @@ struct GPSStatus: View { } } } + +#Preview { + GPSStatus() +} diff --git a/Meshtastic/Views/Settings/Logs/AppLogFilter.swift b/Meshtastic/Views/Settings/Logs/AppLogFilter.swift index 52927149..8b0bfbb3 100644 --- a/Meshtastic/Views/Settings/Logs/AppLogFilter.swift +++ b/Meshtastic/Views/Settings/Logs/AppLogFilter.swift @@ -169,3 +169,7 @@ struct AppLogFilter: View { .presentationBackgroundInteraction(.enabled(upThrough: .medium)) } } + +#Preview { + AppLogFilter(categories: .constant(Set()), levels: .constant(Set())) +} diff --git a/Meshtastic/Views/Settings/TAKServerConfig.swift b/Meshtastic/Views/Settings/TAKServerConfig.swift index cb8e3666..7e8b6502 100644 --- a/Meshtastic/Views/Settings/TAKServerConfig.swift +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -424,9 +424,7 @@ struct TAKServerConfig: View { } } - // MARK: - Channel Label - @ViewBuilder private func channelLabel(_ channel: ChannelEntity) -> some View { if channel.name?.isEmpty ?? false { diff --git a/Meshtastic/Views/Settings/UpdateIntervalPicker.swift b/Meshtastic/Views/Settings/UpdateIntervalPicker.swift index c8601624..46e59088 100644 --- a/Meshtastic/Views/Settings/UpdateIntervalPicker.swift +++ b/Meshtastic/Views/Settings/UpdateIntervalPicker.swift @@ -55,3 +55,11 @@ struct UpdateIntervalPicker: View { } } } + +#Preview { + UpdateIntervalPicker( + config: .broadcastShort, + pickerLabel: "Update Interval", + selectedInterval: .constant(UpdateInterval(from: 30)) + ) +} diff --git a/Meshtastic/Views/Settings/UserConfig.swift b/Meshtastic/Views/Settings/UserConfig.swift index ef0c1ffb..aab5a127 100644 --- a/Meshtastic/Views/Settings/UserConfig.swift +++ b/Meshtastic/Views/Settings/UserConfig.swift @@ -253,3 +253,10 @@ struct UserConfig: View { } } } + +#Preview { + let context = PersistenceController.preview.container.viewContext + return UserConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/MeshtasticTests/ConnectViewTests.swift b/MeshtasticTests/ConnectViewTests.swift index cbbcd331..450591ad 100644 --- a/MeshtasticTests/ConnectViewTests.swift +++ b/MeshtasticTests/ConnectViewTests.swift @@ -55,7 +55,7 @@ struct DeviceTests { (-80, BLESignalStrength.normal), (-84, BLESignalStrength.normal), (-85, BLESignalStrength.weak), - (-100, BLESignalStrength.weak), + (-100, BLESignalStrength.weak) ]) func signalStrength(rssi: Int, expected: BLESignalStrength) { let device = Device( @@ -209,7 +209,7 @@ struct TransportTypeTests { @Test(arguments: [ (TransportType.ble, "BLE"), (TransportType.tcp, "TCP"), - (TransportType.serial, "Serial"), + (TransportType.serial, "Serial") ]) func rawValues(type: TransportType, expected: String) { #expect(type.rawValue == expected) @@ -307,7 +307,7 @@ struct NavigationStateTests { NavigationState.Tab.connect, NavigationState.Tab.nodes, NavigationState.Tab.map, - NavigationState.Tab.settings, + NavigationState.Tab.settings ]) func tabRawValues(tab: NavigationState.Tab) { #expect(NavigationState.Tab(rawValue: tab.rawValue) == tab) diff --git a/MeshtasticTests/RouterTests.swift b/MeshtasticTests/RouterTests.swift index 1175dc59..11af217c 100644 --- a/MeshtasticTests/RouterTests.swift +++ b/MeshtasticTests/RouterTests.swift @@ -214,7 +214,7 @@ struct RouterTests { ("debugLogs", SettingsNavigationState.debugLogs), ("appFiles", SettingsNavigationState.appFiles), ("firmwareUpdates", SettingsNavigationState.firmwareUpdates), - ("tak", SettingsNavigationState.tak), + ("tak", SettingsNavigationState.tak) ]) func routeSettingsPage(path: String, expected: SettingsNavigationState) async throws { try await assertRoute( From 04ef427ec891823a5fd8ff89bb4700c4e2490b05 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:10:45 -0700 Subject: [PATCH 03/34] Show which node created a waypoint and which last updated by (#1496) * Fixed some issues with waypoints and created a createdBy and lastUpdatedBy * Fix suggestions --------- Co-authored-by: Claude Sonnet 4.6 --- Localizable.xcstrings | 10 + Meshtastic/Helpers/MeshPackets.swift | 4 +- .../contents | 2 + .../Map/MapContent/MeshMapContent.swift | 1 + .../Nodes/Helpers/Map/WaypointForm.swift | 673 ++++++++++-------- Meshtastic/Views/Nodes/MeshMap.swift | 7 +- 6 files changed, 401 insertions(+), 296 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 8a7d3aeb..7caaa7cd 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -13802,6 +13802,9 @@ } } } + }, + "Created by:" : { + }, "Created: %@" : { "localizations" : { @@ -28406,6 +28409,9 @@ } } } + }, + "Last updated by:" : { + }, "Later" : { "comment" : "A button that dismisses an alert without taking any action.", @@ -31372,6 +31378,10 @@ "comment" : "A description of the read-only mode feature in TAK Server.", "isCommentAutoGenerated" : true }, + "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings." : { + "comment" : "Privacy policy text for Meshtastic.", + "isCommentAutoGenerated" : true + }, "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings." : { "localizations" : { "es" : { diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index f6b8c485..4c7ab01e 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -1175,7 +1175,7 @@ actor MeshPackets { // Fetch waypoint by waypointMessage.id, not packet.id let fetchWaypointRequest = WaypointEntity.fetchRequest() fetchWaypointRequest.predicate = NSPredicate(format: "id == %lld", Int64(waypointMessage.id)) - + let fetchedWaypoint = try context.fetch(fetchWaypointRequest) // Fetch the node info to get the short name var nodeShortName: String = "?" @@ -1199,6 +1199,7 @@ actor MeshPackets { waypoint.longitudeI = waypointMessage.longitudeI waypoint.icon = Int64(waypointMessage.icon) waypoint.locked = Int64(waypointMessage.lockedTo) + waypoint.createdBy = Int64(packet.from) if waypointMessage.expire >= 1 { waypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) } else { @@ -1254,6 +1255,7 @@ actor MeshPackets { existingWaypoint.longitudeI = waypointMessage.longitudeI existingWaypoint.icon = Int64(waypointMessage.icon) existingWaypoint.locked = Int64(waypointMessage.lockedTo) + existingWaypoint.lastUpdatedBy = Int64(packet.from) if waypointMessage.expire >= 1 { existingWaypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) } else { diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents index a6e5465f..bc7e7d0b 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents @@ -490,10 +490,12 @@ + + diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 480a5cba..39e42baa 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -177,6 +177,7 @@ struct MeshMapContent: MapContent { } } } + .annotationTitles(.automatic) } } } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift index b441bfb4..6054e4af 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift @@ -10,6 +10,7 @@ import MapKit import MeshtasticProtobufs import OSLog import SwiftUI +import CoreData struct WaypointForm: View { @@ -31,134 +32,218 @@ struct WaypointForm: View { @State private var lockedTo: Int64 = 0 @State private var selectedDetent: PresentationDetent = .medium @State private var waypointFailedAlert: Bool = false + @State private var createdByNode : NodeInfoEntity? = nil + @State private var lastUpdatedByNode : NodeInfoEntity? = nil + var body: some View { - NavigationStack { - if editMode { - Text((waypoint.id > 0) ? "Editing Waypoint" : "Create Waypoint") - .font(.largeTitle) - Divider() - Form { - if let cl = LocationsHandler.currentLocation { - let distance = CLLocation(latitude: cl.latitude, longitude: cl.longitude).distance(from: CLLocation(latitude: waypoint.coordinate.latitude, longitude: waypoint.coordinate.longitude )) - Section(header: Text("Coordinate") ) { + Group { + if editMode { + Text((waypoint.id > 0) ? "Editing Waypoint" : "Create Waypoint") + .font(.largeTitle) + Divider() + Form { + if let cl = LocationsHandler.currentLocation { + let distance = CLLocation(latitude: cl.latitude, longitude: cl.longitude).distance(from: CLLocation(latitude: waypoint.coordinate.latitude, longitude: waypoint.coordinate.longitude )) + Section(header: Text("Coordinate") ) { + HStack { + Text("Location:") + .foregroundColor(.secondary) + Text("\(String(format: "%.5f", waypoint.coordinate.latitude) + "," + String(format: "%.5f", waypoint.coordinate.longitude))") + .textSelection(.enabled) + .foregroundColor(.secondary) + .font(.caption) + + } + Button { + waypoint.coordinate.longitude = cl.longitude + waypoint.coordinate.latitude = cl.latitude + } label: { HStack { - Text("Location:") - .foregroundColor(.secondary) - Text("\(String(format: "%.5f", waypoint.coordinate.latitude) + "," + String(format: "%.5f", waypoint.coordinate.longitude))") - .textSelection(.enabled) - .foregroundColor(.secondary) - .font(.caption) - - } - Button { - waypoint.coordinate.longitude = cl.longitude - waypoint.coordinate.latitude = cl.latitude - } label: { - HStack { - Text("Use my Location") - Image(systemName: "location") - } - } - .accessibilityLabel("Set to current location") - HStack { - if waypoint.coordinate.latitude != 0 && waypoint.coordinate.longitude != 0 { - DistanceText(meters: distance) - .foregroundColor(Color.gray) - } + Text("Use my Location") + Image(systemName: "location") } } - } - Section(header: Text("Waypoint Options")) { + .accessibilityLabel("Set to current location") 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" + if waypoint.coordinate.latitude != 0 && waypoint.coordinate.longitude != 0 { + DistanceText(meters: distance) + .foregroundColor(Color.gray) } } - 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(.title) - .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)) } } - .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).. 30 { + name = String(name.dropLast()) + totalBytes = name.utf8.count } - newWaypoint.latitudeI = waypoint.latitudeI - newWaypoint.longitudeI = waypoint.longitudeI + 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() + EmojiOnlyTextField(text: $icon, placeholder: "Select an emoji") + .font(.title) + .focused($iconIsFocused) + .onChange(of: icon) { + // If it contains non-emoji characters, clear it + if !icon.onlyEmojis() { + icon = "" + return + } + + // If multiple emojis are entered or pasted, keep only the last one + if icon.count > 1 { + icon = String(icon.suffix(1)) + } + iconIsFocused = false + } + + + } + 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 + // 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) + } + } + 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 { + context.rollback() + } + 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 @@ -171,101 +256,28 @@ struct WaypointForm: View { newWaypoint.lockedTo = UInt32(lockedTo) } } - if expires { - newWaypoint.expire = UInt32(expire.timeIntervalSince1970) - } else { - newWaypoint.expire = 0 - } - + newWaypoint.expire = UInt32(1) Task { do { try await accessoryManager.sendWaypoint(waypoint: newWaypoint) - dismiss() - } catch { - Logger.mesh.warning("Send waypoint failed: \(error)") Task { @MainActor in + context.delete(waypoint) + do { + try context.save() + } catch { + context.rollback() + } + dismiss() + } + } catch { + Logger.mesh.warning("Send waypoint failed") + 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 { - context.rollback() - } - 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 { - context.rollback() - } - dismiss() - } - } catch { - Logger.mesh.warning("Send waypoint failed") - Task {@MainActor in - waypointFailedAlert = true - } - } - } - }) - } label: { Label("Delete", systemImage: "trash") .foregroundColor(.red) @@ -274,130 +286,167 @@ struct WaypointForm: View { .buttonBorderShape(.capsule) .controlSize(.regular) .padding(.bottom) + } + } + } else { + VStack { + HStack { + CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "πŸ“"), color: Color.orange, circleSize: 50) + Spacer() + Text(waypoint.name ?? "?") + .font(.largeTitle) + Spacer() + if waypoint.locked > 0 && waypoint.locked != UInt32(accessoryManager.activeDeviceNum ?? 0) { + Image(systemName: "lock.fill") + .font(.largeTitle) + } else { + Button { + editMode = true + selectedDetent = .fraction(0.85) + } label: { + Image(systemName: "square.and.pencil" ) + .font(.largeTitle) + .symbolRenderingMode(.hierarchical) + .foregroundColor(.accentColor) + } } } - } else { - VStack { - HStack { - CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "πŸ“"), color: Color.orange, circleSize: 50) - Spacer() - Text(waypoint.name ?? "?") - .font(.largeTitle) - Spacer() - if waypoint.locked > 0 && waypoint.locked != UInt32(accessoryManager.activeDeviceNum ?? 0) { - Image(systemName: "lock.fill") - .font(.largeTitle) - } else { - Button { - editMode = true - selectedDetent = .fraction(0.85) - } label: { - Image(systemName: "square.and.pencil" ) - .font(.largeTitle) - .symbolRenderingMode(.hierarchical) - .foregroundColor(.accentColor) + Divider() + VStack(alignment: .leading) { + + // Nodes who created/modified + VStack(alignment: .leading, spacing: 12) { + if let created = createdByNode { + VStack(alignment: .leading, spacing: 6) { + Text("Created by:") + .font(.headline) + + HStack(spacing: 8) { + CircleText( + text: created.user?.shortName ?? "?", + color: Color(UIColor(hex: UInt32(created.user?.num ?? 0x808080))) + ) + Text(created.user?.longName ?? "Unknown") + .font(.body) + } + } + } + + if let updated = lastUpdatedByNode { + VStack(alignment: .leading, spacing: 6) { + Text("Last updated by:") + .font(.headline) + + HStack(spacing: 8) { + CircleText( + text: updated.user?.shortName ?? "?", + color: Color(UIColor(hex: UInt32(updated.user?.num ?? 0x808080))) + ) + Text(updated.user?.longName ?? "Unknown") + .font(.body) + } } } } - Divider() - VStack(alignment: .leading) { - // Description - if (waypoint.longDescription ?? "").count > 0 { - Label { - Text(waypoint.longDescription ?? "") - .foregroundColor(.primary) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } icon: { - Image(systemName: "doc.plaintext") - } - .padding(.bottom) - } - /// Coordinate + .padding(.bottom) + + // Description + if (waypoint.longDescription ?? "").count > 0 { Label { - Text("Coordinates:") + Text(waypoint.longDescription ?? "") .foregroundColor(.primary) - Text("\(String(format: "%.6f", waypoint.coordinate.latitude)), \(String(format: "%.6f", waypoint.coordinate.longitude))") + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) - .foregroundColor(.secondary) - .font(.caption2) } icon: { - Image(systemName: "mappin.circle") + Image(systemName: "doc.plaintext") } .padding(.bottom) - // Drop Maps Pin - Button(action: { - if let url = URL(string: "http://maps.apple.com/?ll=\(waypoint.coordinate.latitude),\(waypoint.coordinate.longitude)&q=\(waypoint.name ?? "Dropped Pin")") { - UIApplication.shared.open(url) - } - }) { - Label("Drop Pin in Maps", systemImage: "mappin.and.ellipse") + } + /// Coordinate + Label { + Text("Coordinates:") + .foregroundColor(.primary) + Text("\(String(format: "%.6f", waypoint.coordinate.latitude)), \(String(format: "%.6f", waypoint.coordinate.longitude))") + .textSelection(.enabled) + .foregroundColor(.secondary) + .font(.caption2) + } icon: { + Image(systemName: "mappin.circle") + } + .padding(.bottom) + // Drop Maps Pin + Button(action: { + if let url = URL(string: "http://maps.apple.com/?ll=\(waypoint.coordinate.latitude),\(waypoint.coordinate.longitude)&q=\(waypoint.name ?? "Dropped Pin")") { + UIApplication.shared.open(url) } - .padding(.bottom) - /// Created + }) { + Label("Drop Pin in Maps", systemImage: "mappin.and.ellipse") + } + .padding(.bottom) + /// Created + Label { + Text("Created: \(waypoint.created?.formatted() ?? "?")") + .foregroundColor(.primary) + } icon: { + Image(systemName: "clock.badge.checkmark") + .symbolRenderingMode(.hierarchical) + } + .padding(.bottom) + /// Updated + if waypoint.lastUpdated != nil { Label { - Text("Created: \(waypoint.created?.formatted() ?? "?")") + Text("Updated: \(waypoint.lastUpdated?.formatted() ?? "?")") .foregroundColor(.primary) } icon: { - Image(systemName: "clock.badge.checkmark") + Image(systemName: "clock.arrow.circlepath") .symbolRenderingMode(.hierarchical) } .padding(.bottom) - /// Updated - if waypoint.lastUpdated != nil { - Label { - Text("Updated: \(waypoint.lastUpdated?.formatted() ?? "?")") - .foregroundColor(.primary) - } icon: { - Image(systemName: "clock.arrow.circlepath") - .symbolRenderingMode(.hierarchical) - } - .padding(.bottom) + } + /// Expires + if waypoint.expire != nil { + Label { + Text("Expires: \(waypoint.expire?.formatted() ?? "?")") + .foregroundColor(.primary) + } icon: { + Image(systemName: "hourglass.bottomhalf.filled") + .symbolRenderingMode(.hierarchical) } - /// Expires - if waypoint.expire != nil { + .padding(.bottom, 5) + } + /// Distance + if let cl = LocationsHandler.currentLocation { + if cl.distance(from: cl) > 0.0 { + let metersAway = waypoint.coordinate.distance(from: cl) Label { - Text("Expires: \(waypoint.expire?.formatted() ?? "?")") + Text("Distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway)))") .foregroundColor(.primary) } icon: { - Image(systemName: "hourglass.bottomhalf.filled") + Image(systemName: "lines.measurement.horizontal") .symbolRenderingMode(.hierarchical) .frame(width: 35) } .padding(.bottom, 5) } - /// Distance - if let cl = LocationsHandler.currentLocation { - if cl.distance(from: cl) > 0.0 { - let metersAway = waypoint.coordinate.distance(from: cl) - Label { - Text("Distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway)))") - .foregroundColor(.primary) - } icon: { - Image(systemName: "lines.measurement.horizontal") - .symbolRenderingMode(.hierarchical) - .frame(width: 35) - } - .padding(.bottom, 5) - } - } } - .padding(.top) -#if targetEnvironment(macCatalyst) - Spacer() - Button { - dismiss() - } label: { - Label("Close", systemImage: "xmark") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() -#endif } + .padding(.top) +#if targetEnvironment(macCatalyst) + Spacer() + Button { + dismiss() + } label: { + Label("Close", systemImage: "xmark") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() +#endif } } + } .alert("Waypoint Failed to Send", isPresented: $waypointFailedAlert) { Button("OK", role: .cancel) { context.delete(waypoint) @@ -421,6 +470,9 @@ struct WaypointForm: View { } } } + .task { + await fetchNodeInfo() + } .onAppear { if waypoint.id > 0 { let waypoint = getWaypoint(id: Int64(waypoint.id), context: context) @@ -453,4 +505,37 @@ struct WaypointForm: View { .presentationBackgroundInteraction(.enabled(upThrough: .fraction(0.85))) .presentationDragIndicator(.visible) } + + private func fetchNodeInfo() async { + // --- Fetch createdBy node --- + if waypoint.createdBy != 0 { + let createdByFetch: NSFetchRequest = NodeInfoEntity.fetchRequest() + createdByFetch.predicate = NSPredicate(format: "num == %lld", Int64(waypoint.createdBy)) + createdByFetch.fetchLimit = 1 + + do { + let nodes = try context.fetch(createdByFetch) + createdByNode = nodes.first + } catch { + Logger.services.warning("Error fetching createdBy node: \(error.localizedDescription)") + } + } + + // --- Fetch lastUpdatedBy node (only if different from createdBy) --- + if waypoint.lastUpdatedBy != 0, + waypoint.lastUpdatedBy != waypoint.createdBy { + let updatedByFetch: NSFetchRequest = NodeInfoEntity.fetchRequest() + updatedByFetch.predicate = NSPredicate(format: "num == %lld", Int64(waypoint.lastUpdatedBy)) + updatedByFetch.fetchLimit = 1 + + do { + let nodes = try context.fetch(updatedByFetch) + lastUpdatedByNode = nodes.first + } catch { + Logger.services.warning("Error fetching lastUpdatedBy node: \(error.localizedDescription)") + } + } + } } + + diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index 3e268afa..b1bf58ba 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -120,12 +120,17 @@ struct MeshMap: View { } .sheet(item: $selectedWaypoint) { selection in WaypointForm(waypoint: selection) - .presentationDetents([.large]) + .padding() + .presentationDetents([.large]) // full screen + .presentationDragIndicator(.visible) } .sheet(item: $editingWaypoint) { selection in WaypointForm(waypoint: selection, editMode: true) + .padding() .presentationDetents([.large]) + .presentationDragIndicator(.visible) } + .sheet(isPresented: $editingSettings) { MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs) } From a4422b32cb4a212dc8a8f2f18a1b7728f63fd14b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:51:00 -0700 Subject: [PATCH 04/34] perf: Quick-win performance optimizations for node list and Core Data lookups (#1650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: improve routing performance with split state, fetch batching, node cache, and debounce - Split Router's single @Published navigationState into per-tab properties to reduce spurious re-renders across unrelated views - Add fetchBatchSize=50 and relationshipKeyPathsForPrefetching to node list - Optimize in-body array re-sort from 2 filter passes to single pass - Add in-memory node object ID cache on Router for O(1) lookups - Add fetchLimit=1 to getNodeInfo for early termination - Debounce rapid node selection changes with 100ms Task delay Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/9bfe91f2-8ed7-4d2c-bb2e-4ed3dfd3a16c Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Address code review: add debounce constant and thread-safety comment Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/9bfe91f2-8ed7-4d2c-bb2e-4ed3dfd3a16c Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Migrate test project to Swift Testing and add connect view and router tests (#1643) * Migrate to Swift Testing and add connect view tests - Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require) - Create ConnectViewTests.swift with tests for connect view child types: - Device struct (creation, signal strength, RSSI, description, codable) - TransportType enum (cases, raw values, codable) - ConnectionState enum (equality, codable) - BLESignalStrength enum (raw values, init) - TransportStatus enum (equality) - NavigationState (defaults, tabs, sub-states) - InvalidVersion view (creation with versions) - ConnectedDevice view (connected/disconnected/MQTT states) - CircleText view (default/custom sizes, emoji) - BatteryCompact view (levels, nil, charging, plugged in) - SignalStrengthIndicator view (dimensions, strength levels) - Update Xcode project to include new test file Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix signal strength test boundary conditions The getSignalStrength() method uses NSNumber.compare(.orderedDescending), which is a strict greater-than check. Fix the boundary test cases: - RSSI -65 is .normal (not .strong), since -65 is not > -65 - RSSI -85 is .weak (not .normal), since -85 is not > -85 - Add -64 β†’ .strong and -84 β†’ .normal as adjacent boundary tests Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve and complete router tests with comprehensive coverage Added tests for: - Custom initial state - Invalid scheme / unknown path handling (state unchanged) - navigateToNodeDetail public method - Messages edge cases: channelId only, userNum only, messageId only, non-numeric params - Nodes with non-numeric nodenum - Map with both nodenum+waypointId (nodeId priority), non-numeric params - Parameterized settings test covering all 31 SettingsNavigationState cases - State transitions: consecutive routes, invalid scheme preserves existing state Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Localizable update * Merge translations file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen * Fix merge conflicts in PR #1614 (Spanish translations) (#1644) * 20% of strings translated to spanish * add more translations * add rest of translations * small fixes --------- Co-authored-by: Joel PΓ©rez Izquierdo Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix typo in hop limit option description (#1631) O hop -> 0 hop Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Tak server improvements (#1603) * added read only mode cot to meshtastic parsing and warning to not enable on pub channel * better icons * fully fixed markers * telemetry support * Update Meshtastic/Helpers/TAK/TAKServerManager.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixes * fixes * Resolve merge conflicts for PR #1603 (TAK server improvements) (#1645) * Delete Messages fix * Bump version to 2.7.9 * Bump widgets version * TAK Server channel index picker Create a settings picker for the TAK Server's channel index. This allows users to specify TAK traffic to use the non-primary channel to help reduce channel congestion. * Changed capitalization from 'environment' to 'Environment' for section header. (#1591) * Add Danish (da) translations β€” resolves merge conflicts from PR #1609 (#1612) * Initial plan * Add Danish (da) translations from PR #1609 Resolves merge conflicts from PR #1609 by adding Danish translations to the Localizable.xcstrings file. The PR adds Danish translation strings throughout the app while preserving all existing translations for other languages. Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Migrate test project to Swift Testing and add connect view and router tests (#1643) * Migrate to Swift Testing and add connect view tests - Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require) - Create ConnectViewTests.swift with tests for connect view child types: - Device struct (creation, signal strength, RSSI, description, codable) - TransportType enum (cases, raw values, codable) - ConnectionState enum (equality, codable) - BLESignalStrength enum (raw values, init) - TransportStatus enum (equality) - NavigationState (defaults, tabs, sub-states) - InvalidVersion view (creation with versions) - ConnectedDevice view (connected/disconnected/MQTT states) - CircleText view (default/custom sizes, emoji) - BatteryCompact view (levels, nil, charging, plugged in) - SignalStrengthIndicator view (dimensions, strength levels) - Update Xcode project to include new test file Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix signal strength test boundary conditions The getSignalStrength() method uses NSNumber.compare(.orderedDescending), which is a strict greater-than check. Fix the boundary test cases: - RSSI -65 is .normal (not .strong), since -65 is not > -65 - RSSI -85 is .weak (not .normal), since -85 is not > -85 - Add -64 β†’ .strong and -84 β†’ .normal as adjacent boundary tests Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve and complete router tests with comprehensive coverage Added tests for: - Custom initial state - Invalid scheme / unknown path handling (state unchanged) - navigateToNodeDetail public method - Messages edge cases: channelId only, userNum only, messageId only, non-numeric params - Nodes with non-numeric nodenum - Map with both nodenum+waypointId (nodeId priority), non-numeric params - Parameterized settings test covering all 31 SettingsNavigationState cases - State transitions: consecutive routes, invalid scheme preserves existing state Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Localizable update * Merge translations file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen * Fix merge conflicts in PR #1614 (Spanish translations) (#1644) * 20% of strings translated to spanish * add more translations * add rest of translations * small fixes --------- Co-authored-by: Joel PΓ©rez Izquierdo Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix typo in hop limit option description (#1631) O hop -> 0 hop --------- Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel PΓ©rez Izquierdo Co-authored-by: axunes * Fix merge conflicts * Merge main into tak-server-improvements to resolve PR #1603 conflicts (#1646) * Delete Messages fix * Bump version to 2.7.9 * Bump widgets version * TAK Server channel index picker Create a settings picker for the TAK Server's channel index. This allows users to specify TAK traffic to use the non-primary channel to help reduce channel congestion. * Changed capitalization from 'environment' to 'Environment' for section header. (#1591) * Add Danish (da) translations β€” resolves merge conflicts from PR #1609 (#1612) * Initial plan * Add Danish (da) translations from PR #1609 Resolves merge conflicts from PR #1609 by adding Danish translations to the Localizable.xcstrings file. The PR adds Danish translation strings throughout the app while preserving all existing translations for other languages. Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Migrate test project to Swift Testing and add connect view and router tests (#1643) * Migrate to Swift Testing and add connect view tests - Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require) - Create ConnectViewTests.swift with tests for connect view child types: - Device struct (creation, signal strength, RSSI, description, codable) - TransportType enum (cases, raw values, codable) - ConnectionState enum (equality, codable) - BLESignalStrength enum (raw values, init) - TransportStatus enum (equality) - NavigationState (defaults, tabs, sub-states) - InvalidVersion view (creation with versions) - ConnectedDevice view (connected/disconnected/MQTT states) - CircleText view (default/custom sizes, emoji) - BatteryCompact view (levels, nil, charging, plugged in) - SignalStrengthIndicator view (dimensions, strength levels) - Update Xcode project to include new test file Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix signal strength test boundary conditions The getSignalStrength() method uses NSNumber.compare(.orderedDescending), which is a strict greater-than check. Fix the boundary test cases: - RSSI -65 is .normal (not .strong), since -65 is not > -65 - RSSI -85 is .weak (not .normal), since -85 is not > -85 - Add -64 β†’ .strong and -84 β†’ .normal as adjacent boundary tests Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve and complete router tests with comprehensive coverage Added tests for: - Custom initial state - Invalid scheme / unknown path handling (state unchanged) - navigateToNodeDetail public method - Messages edge cases: channelId only, userNum only, messageId only, non-numeric params - Nodes with non-numeric nodenum - Map with both nodenum+waypointId (nodeId priority), non-numeric params - Parameterized settings test covering all 31 SettingsNavigationState cases - State transitions: consecutive routes, invalid scheme preserves existing state Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Localizable update * Merge translations file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen * Fix merge conflicts in PR #1614 (Spanish translations) (#1644) * 20% of strings translated to spanish * add more translations * add rest of translations * small fixes --------- Co-authored-by: Joel PΓ©rez Izquierdo Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix typo in hop limit option description (#1631) O hop -> 0 hop --------- Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel PΓ©rez Izquierdo Co-authored-by: axunes * Merge main into tak-server-improvements to resolve PR #1603 conflicts (#1647) * Delete Messages fix * Bump version to 2.7.9 * Bump widgets version * TAK Server channel index picker Create a settings picker for the TAK Server's channel index. This allows users to specify TAK traffic to use the non-primary channel to help reduce channel congestion. * Changed capitalization from 'environment' to 'Environment' for section header. (#1591) * Add Danish (da) translations β€” resolves merge conflicts from PR #1609 (#1612) * Initial plan * Add Danish (da) translations from PR #1609 Resolves merge conflicts from PR #1609 by adding Danish translations to the Localizable.xcstrings file. The PR adds Danish translation strings throughout the app while preserving all existing translations for other languages. Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Migrate test project to Swift Testing and add connect view and router tests (#1643) * Migrate to Swift Testing and add connect view tests - Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require) - Create ConnectViewTests.swift with tests for connect view child types: - Device struct (creation, signal strength, RSSI, description, codable) - TransportType enum (cases, raw values, codable) - ConnectionState enum (equality, codable) - BLESignalStrength enum (raw values, init) - TransportStatus enum (equality) - NavigationState (defaults, tabs, sub-states) - InvalidVersion view (creation with versions) - ConnectedDevice view (connected/disconnected/MQTT states) - CircleText view (default/custom sizes, emoji) - BatteryCompact view (levels, nil, charging, plugged in) - SignalStrengthIndicator view (dimensions, strength levels) - Update Xcode project to include new test file Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix signal strength test boundary conditions The getSignalStrength() method uses NSNumber.compare(.orderedDescending), which is a strict greater-than check. Fix the boundary test cases: - RSSI -65 is .normal (not .strong), since -65 is not > -65 - RSSI -85 is .weak (not .normal), since -85 is not > -85 - Add -64 β†’ .strong and -84 β†’ .normal as adjacent boundary tests Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve and complete router tests with comprehensive coverage Added tests for: - Custom initial state - Invalid scheme / unknown path handling (state unchanged) - navigateToNodeDetail public method - Messages edge cases: channelId only, userNum only, messageId only, non-numeric params - Nodes with non-numeric nodenum - Map with both nodenum+waypointId (nodeId priority), non-numeric params - Parameterized settings test covering all 31 SettingsNavigationState cases - State transitions: consecutive routes, invalid scheme preserves existing state Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Localizable update * Merge translations file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen * Fix merge conflicts in PR #1614 (Spanish translations) (#1644) * 20% of strings translated to spanish * add more translations * add rest of translations * small fixes --------- Co-authored-by: Joel PΓ©rez Izquierdo Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix typo in hop limit option description (#1631) O hop -> 0 hop --------- Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel PΓ©rez Izquierdo Co-authored-by: axunes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel PΓ©rez Izquierdo Co-authored-by: axunes --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen Co-authored-by: Joel PΓ©rez Izquierdo Co-authored-by: axunes Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jake-B Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors --- Localizable.xcstrings | 95 +++++++++++++++++++ Meshtastic.xcodeproj/project.pbxproj | 2 + Meshtastic/Persistence/QueryCoreData.swift | 1 + Meshtastic/Router/Router.swift | 94 +++++++++++++++--- Meshtastic/Views/ContentView.swift | 2 +- .../Views/Messages/ChannelMessageList.swift | 2 +- Meshtastic/Views/Messages/Messages.swift | 12 +-- .../Views/Messages/UserMessageList.swift | 2 +- Meshtastic/Views/Nodes/MeshMap.swift | 4 +- Meshtastic/Views/Nodes/NodeList.swift | 59 ++++++++++-- Meshtastic/Views/Settings/Settings.swift | 4 +- 11 files changed, 240 insertions(+), 37 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 7caaa7cd..3a8d12cf 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -233,6 +233,12 @@ "value" : ": %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -274,6 +280,12 @@ "value" : ": %d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52706,6 +52718,7 @@ } } } + }, "TAK Cannot Be Used on Public Channel" : { "comment" : "A warning displayed when the user's primary channel is public.", @@ -62950,6 +62963,88 @@ } } } + }, + ": %@" : { + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + } + }, + "shouldTranslate" : false + }, + ": %d" : { + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + } + }, + "shouldTranslate" : false } }, "version" : "1.1" diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index ac3d99cf..5038f261 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; }; 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; }; 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; }; + AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; 2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01028778B8BFD81F7A039593 /* TAKConnection.swift */; }; 300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */; }; 3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; }; @@ -412,6 +413,7 @@ 25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; + AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = ""; }; 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = ""; }; 3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = ""; }; 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; }; diff --git a/Meshtastic/Persistence/QueryCoreData.swift b/Meshtastic/Persistence/QueryCoreData.swift index 55889764..dffe425b 100644 --- a/Meshtastic/Persistence/QueryCoreData.swift +++ b/Meshtastic/Persistence/QueryCoreData.swift @@ -11,6 +11,7 @@ public func getNodeInfo(id: Int64, context: NSManagedObjectContext) -> NodeInfoE let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(id)) + fetchNodeInfoRequest.fetchLimit = 1 do { let fetchedNode = try context.fetch(fetchNodeInfoRequest) diff --git a/Meshtastic/Router/Router.swift b/Meshtastic/Router/Router.swift index 4ea89d94..61a599c2 100644 --- a/Meshtastic/Router/Router.swift +++ b/Meshtastic/Router/Router.swift @@ -7,7 +7,67 @@ import SwiftUI class Router: ObservableObject { @Published - var navigationState: NavigationState + var selectedTab: NavigationState.Tab + + @Published + var messagesState: MessagesNavigationState? + + @Published + var nodeListSelectedNodeNum: Int64? + + @Published + var mapState: MapNavigationState? + + @Published + var settingsState: SettingsNavigationState? + + /// Computed property that assembles the individual per-tab properties into a `NavigationState`. + /// Provided for backward compatibility (e.g. tests) and convenience. + var navigationState: NavigationState { + get { + NavigationState( + selectedTab: selectedTab, + messages: messagesState, + nodeListSelectedNodeNum: nodeListSelectedNodeNum, + map: mapState, + settings: settingsState + ) + } + set { + selectedTab = newValue.selectedTab + messagesState = newValue.messages + nodeListSelectedNodeNum = newValue.nodeListSelectedNodeNum + mapState = newValue.map + settingsState = newValue.settings + } + } + + // MARK: Node Object ID Cache + + /// In-memory cache mapping node numbers to their Core Data `NSManagedObjectID` for O(1) lookups. + /// Thread-safe by virtue of Router's @MainActor isolation β€” all access is on the main thread. + private var nodeObjectIDCache: [Int64: NSManagedObjectID] = [:] + + /// Updates the node cache from a set of fetched nodes. Call this when the node list changes. + func updateNodeIndex(from nodes: C) where C.Element: NodeInfoEntity { + nodeObjectIDCache = Dictionary( + nodes.map { ($0.num, $0.objectID) }, + uniquingKeysWith: { _, new in new } + ) + } + + /// Looks up a node using the in-memory cache for O(1) performance, falling back to a Core Data fetch. + func cachedNodeInfo(id: Int64, context: NSManagedObjectContext) -> NodeInfoEntity? { + if let objectID = nodeObjectIDCache[id] { + return try? context.existingObject(with: objectID) as? NodeInfoEntity + } + // Cache miss β€” fall back to standard fetch + let node = getNodeInfo(id: id, context: context) + if let node { + nodeObjectIDCache[id] = node.objectID + } + return node + } private var cancellables: Set = [] @@ -16,10 +76,14 @@ class Router: ObservableObject { selectedTab: .connect ) ) { - self.navigationState = navigationState + self.selectedTab = navigationState.selectedTab + self.messagesState = navigationState.messages + self.nodeListSelectedNodeNum = navigationState.nodeListSelectedNodeNum + self.mapState = navigationState.map + self.settingsState = navigationState.settings - $navigationState.sink { destination in - Logger.services.info("πŸ›£ [App] Routed to \(destination.selectedTab.rawValue, privacy: .public)") + $selectedTab.sink { tab in + Logger.services.info("πŸ›£ [App] Routed to \(tab.rawValue, privacy: .public)") }.store(in: &cancellables) } @@ -36,7 +100,7 @@ class Router: ObservableObject { if components.path == "/messages" { routeMessages(components) } else if components.path == "/connect" { - navigationState.selectedTab = .connect + selectedTab = .connect } else if components.path == "/nodes" { routeNodes(components) } else if components.path == "/map" { @@ -73,8 +137,8 @@ class Router: ObservableObject { } else { nil } - navigationState.selectedTab = .messages - navigationState.messages = state + selectedTab = .messages + messagesState = state } private func routeNodes(_ components: URLComponents) { @@ -83,13 +147,13 @@ class Router: ObservableObject { .value .flatMap(Int64.init) - navigationState.selectedTab = .nodes - navigationState.nodeListSelectedNodeNum = nodeId + selectedTab = .nodes + nodeListSelectedNodeNum = nodeId } func navigateToNodeDetail(nodeNum: Int64) { Logger.services.info("πŸ›£ [App] Direct route to node detail \(nodeNum, privacy: .public)") - navigationState.selectedTab = .nodes - navigationState.nodeListSelectedNodeNum = nodeNum + selectedTab = .nodes + nodeListSelectedNodeNum = nodeNum } private func routeMap(_ components: URLComponents) { @@ -102,8 +166,8 @@ class Router: ObservableObject { .value .flatMap(Int64.init) - navigationState.selectedTab = .map - navigationState.map = if let nodeId { + selectedTab = .map + mapState = if let nodeId { .selectedNode(nodeId) } else if let waypointId { .waypoint(waypointId) @@ -120,7 +184,7 @@ class Router: ObservableObject { .flatMap(String.init) .flatMap(SettingsNavigationState.init(rawValue:)) - navigationState.selectedTab = .settings - navigationState.settings = settingFromPath + selectedTab = .settings + settingsState = settingFromPath } } diff --git a/Meshtastic/Views/ContentView.swift b/Meshtastic/Views/ContentView.swift index ac18b9a4..5e10edc3 100644 --- a/Meshtastic/Views/ContentView.swift +++ b/Meshtastic/Views/ContentView.swift @@ -17,7 +17,7 @@ struct ContentView: View { } var body: some View { - TabView(selection: $appState.router.navigationState.selectedTab) { + TabView(selection: $appState.router.selectedTab) { Messages( router: appState.router, unreadChannelMessages: $appState.unreadChannelMessages, diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index be3959d2..a1c70b89 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -60,7 +60,7 @@ struct ChannelMessageList: View { } private func routerIsShowingThisChannel() -> Bool { - guard appState.router.navigationState.selectedTab == .messages else { return false } + guard appState.router.selectedTab == .messages else { return false } return scenePhase == .active } diff --git a/Meshtastic/Views/Messages/Messages.swift b/Meshtastic/Views/Messages/Messages.swift index 82df1ad9..2f4e2950 100644 --- a/Meshtastic/Views/Messages/Messages.swift +++ b/Meshtastic/Views/Messages/Messages.swift @@ -25,7 +25,7 @@ struct Messages: View { var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { - List(selection: $router.navigationState.messages) { + List(selection: $router.messagesState) { NavigationLink(value: MessagesNavigationState.channels()) { Spacer() Label { @@ -74,7 +74,7 @@ struct Messages: View { .navigationBarTitleDisplayMode(.large) .navigationBarItems(leading: MeshtasticLogo()) } content: { - switch router.navigationState.messages { + switch router.messagesState { case .channels(let channelId, let messageId): ChannelList(node: $node, channelSelection: $channelSelection) // Removed navigationTitle and navigationBarTitleDisplayMode here. @@ -91,12 +91,12 @@ struct Messages: View { // The toolbar is now defined inside ChannelMessageList.swift } else if let userSelection { UserMessageList(user: userSelection) - } else if case .channels = router.navigationState.messages { + } else if case .channels = router.messagesState { Text("Select a channel") - } else if case .directMessages = router.navigationState.messages { + } else if case .directMessages = router.messagesState { Text("Select a conversation") } - }.onChange(of: router.navigationState) { + }.onChange(of: router.messagesState) { setupNavigationState() } } @@ -107,7 +107,7 @@ struct Messages: View { node = getNodeInfo(id: nodeId, context: context) } - guard let state = router.navigationState.messages else { + guard let state = router.messagesState else { channelSelection = nil userSelection = nil return diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 9a3425bc..dc417565 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -57,7 +57,7 @@ struct UserMessageList: View { } private func routerIsShowingThisUser() -> Bool { - guard appState.router.navigationState.selectedTab == .messages else { return false } + guard appState.router.selectedTab == .messages else { return false } return scenePhase == .active } diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index b1bf58ba..6414eb3f 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -134,8 +134,8 @@ struct MeshMap: View { .sheet(isPresented: $editingSettings) { MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs) } - .onChange(of: router.navigationState) { - guard case .map = router.navigationState.selectedTab else { return } + .onChange(of: router.mapState) { + guard case .map = router.selectedTab else { return } // TODO: handle deep link for waypoints } .onChange(of: selectedMapLayer) { _, newMapLayer in diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index c751f84a..798e4d6e 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -11,10 +11,14 @@ import CoreData import Foundation struct NodeList: View { + /// Debounce delay for node selection changes (100ms) + private static let nodeSelectionDebounceNs: UInt64 = 100_000_000 + @Environment(\.managedObjectContext) var context @EnvironmentObject var accessoryManager: AccessoryManager @StateObject var router: Router @State private var selectedNode: NodeInfoEntity? + @State private var nodeSelectionTask: Task? @State private var isPresentingTraceRouteSentAlert = false @State private var isPresentingPositionSentAlert = false @State private var isPresentingPositionFailedAlert = false @@ -35,6 +39,7 @@ struct NodeList: View { var body: some View { NavigationSplitView { FilteredNodeList( + router: router, withFilters: filters, selectedNode: $selectedNode, connectedNode: connectedNode, @@ -122,18 +127,27 @@ struct NodeList: View { ContentUnavailableView("Select a Node", systemImage: "flipphone") } } - .onChange(of: router.navigationState.nodeListSelectedNodeNum) { _, newNum in - if let num = newNum { - self.selectedNode = getNodeInfo(id: num, context: context) - } else { - self.selectedNode = nil + .onChange(of: router.nodeListSelectedNodeNum) { _, newNum in + // Debounce rapid route changes β€” only process the last selection after a short delay + nodeSelectionTask?.cancel() + nodeSelectionTask = Task { + do { + try await Task.sleep(nanoseconds: Self.nodeSelectionDebounceNs) + } catch { + return // Cancelled by a newer selection + } + if let num = newNum { + self.selectedNode = router.cachedNodeInfo(id: num, context: context) + } else { + self.selectedNode = nil + } } } .onChange(of: selectedNode) { _, node in if let num = node?.num { - router.navigationState.nodeListSelectedNodeNum = num + router.nodeListSelectedNodeNum = num } else { - router.navigationState.nodeListSelectedNodeNum = nil + router.nodeListSelectedNodeNum = nil } } } @@ -154,6 +168,7 @@ fileprivate struct FilteredNodeList: View { @EnvironmentObject var accessoryManager: AccessoryManager @FetchRequest private var nodes: FetchedResults @Environment(\.managedObjectContext) var context + var router: Router @Binding var selectedNode: NodeInfoEntity? var connectedNode: NodeInfoEntity? @@ -163,6 +178,7 @@ fileprivate struct FilteredNodeList: View { // The initializer for the FetchRequest init( + router: Router, withFilters: NodeFilterParameters, selectedNode: Binding, connectedNode: NodeInfoEntity?, @@ -170,6 +186,7 @@ fileprivate struct FilteredNodeList: View { deleteNodeId: Binding, shareContactNode: Binding ) { + self.router = router let request: NSFetchRequest = NodeInfoEntity.fetchRequest() request.sortDescriptors = [ NSSortDescriptor(key: "ignored", ascending: true), @@ -178,6 +195,8 @@ fileprivate struct FilteredNodeList: View { NSSortDescriptor(key: "user.longName", ascending: true) ] request.predicate = withFilters.buildPredicate() + request.fetchBatchSize = 50 + request.relationshipKeyPathsForPrefetching = ["user"] self._nodes = FetchRequest(fetchRequest: request) self._selectedNode = selectedNode @@ -189,8 +208,24 @@ fileprivate struct FilteredNodeList: View { // The body of the view var body: some View { - // If the connected node passes filters, always show it first - let nodesWithConnectedFirst = nodes.filter { $0.num == accessoryManager.activeDeviceNum } + nodes.filter { $0.num != accessoryManager.activeDeviceNum } + // If the connected node passes filters, always show it first (single-pass) + let nodesWithConnectedFirst: [NodeInfoEntity] = { + let activeNum = accessoryManager.activeDeviceNum + var result: [NodeInfoEntity] = [] + result.reserveCapacity(nodes.count) + var connectedNode: NodeInfoEntity? + for node in nodes { + if node.num == activeNum { + connectedNode = node + } else { + result.append(node) + } + } + if let connectedNode { + result.insert(connectedNode, at: 0) + } + return result + }() List(nodesWithConnectedFirst, id: \.self, selection: $selectedNode) { node in NavigationLink(value: node) { NodeListItem( @@ -206,6 +241,12 @@ fileprivate struct FilteredNodeList: View { ) } } + .onAppear { + router.updateNodeIndex(from: nodes) + } + .onChange(of: nodes.count) { _, _ in + router.updateNodeIndex(from: nodes) + } } @ViewBuilder diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 449efc6c..1c953c73 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -343,10 +343,10 @@ struct Settings: View { NavigationStack( path: Binding<[SettingsNavigationState]>( get: { - [router.navigationState.settings].compactMap { $0 } + [router.settingsState].compactMap { $0 } }, set: { newPath in - router.navigationState.settings = newPath.first + router.settingsState = newPath.first } ) ) { From d6b8c593a06b9881f56efb41ffe069d58d7c80f7 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 5 Apr 2026 08:29:02 -0700 Subject: [PATCH 05/34] Try and fix ios 17 memory leak --- Meshtastic/Persistence/Persistence.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Persistence/Persistence.swift b/Meshtastic/Persistence/Persistence.swift index 4b0fd147..25c7e65b 100644 --- a/Meshtastic/Persistence/Persistence.swift +++ b/Meshtastic/Persistence/Persistence.swift @@ -45,7 +45,7 @@ class PersistenceController { // Merge policy that favors in memory data over data in the db self.container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy self.container.viewContext.automaticallyMergesChangesFromParent = true - self.container.viewContext.retainsRegisteredObjects = true + self.container.viewContext.shouldDeleteInaccessibleFaults = true if let error = error as NSError? { From 4b847b6b088ce10ec2caed3a7a9c12e5c5a14ceb Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 5 Apr 2026 08:43:44 -0700 Subject: [PATCH 06/34] Fix emojionlytextfield regression by @rcgv1 --- Localizable.xcstrings | 102 +---- Meshtastic/Views/Helpers/CompassView.swift | 369 ++++++++++-------- .../Nodes/Helpers/Map/WaypointForm.swift | 23 +- 3 files changed, 222 insertions(+), 272 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 3a8d12cf..e8c21ff6 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -233,12 +233,6 @@ "value" : ": %@" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : " : %@" - } - }, "it" : { "stringUnit" : { "state" : "translated", @@ -280,12 +274,6 @@ "value" : ": %d" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : " : %d" - } - }, "it" : { "stringUnit" : { "state" : "translated", @@ -7774,6 +7762,7 @@ } }, "Bearing: %@" : { + "extractionState" : "stale", "localizations" : { "ru" : { "stringUnit" : { @@ -7784,6 +7773,7 @@ } }, "Bearing: N/A" : { + "extractionState" : "stale", "localizations" : { "ru" : { "stringUnit" : { @@ -17895,6 +17885,7 @@ } }, "Distance: %@" : { + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -31390,10 +31381,6 @@ "comment" : "A description of the read-only mode feature in TAK Server.", "isCommentAutoGenerated" : true }, - "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings." : { - "comment" : "Privacy policy text for Meshtastic.", - "isCommentAutoGenerated" : true - }, "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings." : { "localizations" : { "es" : { @@ -52718,7 +52705,6 @@ } } } - }, "TAK Cannot Be Used on Public Channel" : { "comment" : "A warning displayed when the user's primary channel is public.", @@ -62963,88 +62949,6 @@ } } } - }, - ": %@" : { - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - } - }, - "shouldTranslate" : false - }, - ": %d" : { - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - } - }, - "shouldTranslate" : false } }, "version" : "1.1" diff --git a/Meshtastic/Views/Helpers/CompassView.swift b/Meshtastic/Views/Helpers/CompassView.swift index c7185acf..c8c0b9bc 100644 --- a/Meshtastic/Views/Helpers/CompassView.swift +++ b/Meshtastic/Views/Helpers/CompassView.swift @@ -13,9 +13,9 @@ struct CompassView: View { /// Single waypoint parameter let waypointLocation: CLLocationCoordinate2D? - + let waypointName: String? - + let color: Color @ObservedObject private var locationsHandler = LocationsHandler.shared @@ -24,6 +24,8 @@ struct CompassView: View { private let alignmentTolerance: Double = 5.0 @State private var inAlignment = false + private let dialRadius: CGFloat = 140 + // Compute bearing from user β†’ waypoint private func bearingToWaypoint() -> Double? { guard @@ -39,10 +41,9 @@ struct CompassView: View { // Trigger a vibration if aligned with waypoint private func checkAlignment(bearing: Double, heading: Double) { - // Compute minimal angular difference between heading and bearing in [0, 180] - let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360) - let diff = min(rawDiff, 360 - rawDiff) - + let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360) + let diff = min(rawDiff, 360 - rawDiff) + if diff <= alignmentTolerance { if !inAlignment { inAlignment = true @@ -53,124 +54,246 @@ struct CompassView: View { inAlignment = false } } - + private func distanceToWaypoint() -> CLLocationDistance? { guard let waypoint = waypointLocation, let user = LocationsHandler.currentLocation else { return nil } - + let userLocation = CLLocation(latitude: user.latitude, longitude: user.longitude) let waypointLocation = CLLocation(latitude: waypoint.latitude, longitude: waypoint.longitude) - + return userLocation.distance(from: waypointLocation) } - // Format distance with localization private func formatDistance(_ distance: CLLocationDistance) -> String { let measurement = Measurement(value: distance, unit: UnitLength.meters) let formatter = MeasurementFormatter() formatter.unitOptions = .naturalScale - formatter.numberFormatter.maximumFractionDigits = 2 + formatter.numberFormatter.maximumFractionDigits = 1 return formatter.string(from: measurement) } - + var body: some View { NavigationStack { - VStack(spacing: 15) { - - VStack(spacing: 8) { - Text(waypointName ?? "Waypoint") - .font(.title2) - .bold() - .foregroundColor(color) - + ZStack { + Color.black.ignoresSafeArea() + + VStack(spacing: 0) { + // Top fixed heading indicator triangle + Image(systemName: "triangle.fill") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .rotationEffect(.degrees(180)) + .padding(.bottom, 4) + + // Rotating compass dial + ZStack { + // Outer bezel ring + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 1.5) + .frame(width: dialRadius * 2 + 20, height: dialRadius * 2 + 20) + + // Tick marks + ForEach(0..<360, id: \.self) { degree in + CompassTickMark(degree: Double(degree), radius: dialRadius) + } + + // Cardinal and intercardinal labels + ForEach(CompassLabel.allLabels, id: \.degrees) { label in + CompassLabelView(label: label, radius: dialRadius - 28) + .rotationEffect(.degrees(-locationsHandler.heading)) + } + + // North triangle indicator at 0Β° + CompassNorthIndicator(radius: dialRadius + 2) + + // Degree readout at center + VStack(spacing: 4) { + Text(headingText()) + .font(.system(size: 42, weight: .light, design: .rounded)) + .foregroundColor(.white) + .monospacedDigit() + + if let distance = distanceToWaypoint() { + Text(formatDistance(distance)) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundColor(color) + } + + if waypointName != nil || waypointLocation != nil { + Text(waypointName ?? "Waypoint") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(color.opacity(0.8)) + } + } + + // Waypoint bearing indicator + if let bearing = bearingToWaypoint() { + WaypointMarkerView( + bearing: bearing, + radius: dialRadius + 14, + color: color + ) + .onChange(of: locationsHandler.heading) { _, _ in + checkAlignment(bearing: bearing, heading: locationsHandler.heading) + } + } + } + .frame(width: dialRadius * 2 + 40, height: dialRadius * 2 + 40) + .rotationEffect(Angle(degrees: -locationsHandler.heading)) + + // Bottom info if let wp = waypointLocation { - HStack { - Image(systemName: "mappin.and.ellipse") - Text("\(String(format: "%.4f", wp.latitude)), \(String(format: "%.4f", wp.longitude))") - .font(.subheadline) - } - - if let distance = distanceToWaypoint() { - HStack { - Image(systemName: "lines.measurement.horizontal") - Text("Distance: \(formatDistance(distance))") - .font(.subheadline) - .fontWeight(.semibold) + VStack(spacing: 6) { + HStack(spacing: 4) { + Image(systemName: "mappin") + .font(.system(size: 11)) + Text("\(String(format: "%.4f", wp.latitude)), \(String(format: "%.4f", wp.longitude))") + .font(.system(size: 12, design: .monospaced)) } - } - HStack { - Image(systemName: "location.north") + .foregroundColor(.white.opacity(0.5)) + if let bearing = bearingToWaypoint() { - Text("Bearing: \(String(format: "%.0fΒ°", bearing))") - .font(.subheadline) - } else { - Text("Bearing: N/A") - .font(.subheadline) + HStack(spacing: 4) { + Image(systemName: "location.north.fill") + .font(.system(size: 11)) + .rotationEffect(.degrees(bearing)) + Text("\(String(format: "%.0fΒ°", bearing))") + .font(.system(size: 12, weight: .medium, design: .monospaced)) + } + .foregroundColor(color.opacity(0.7)) } } + .padding(.top, 20) } } - .padding() - - Capsule() - .frame(width: 5, height: 50) - ZStack { - - // Cardinal/degree markers - ForEach(Marker.markers(), id: \.self) { marker in - CompassMarkerView( - marker: marker, - compassDegrees: -locationsHandler.heading - ) - } - - // Waypoint bearing indicator - if let bearing = bearingToWaypoint() { - WaypointMarkerView( - bearing: bearing, - compassDegrees: locationsHandler.heading, - color: color - ) - // Move waypoint marker outside compass - .onChange(of: locationsHandler.heading) { _, _ in - checkAlignment(bearing: bearing, heading:locationsHandler.heading) - } - } - - } - .frame(width: 300, height: 300) - .rotationEffect(Angle(degrees: -locationsHandler.heading)) - .statusBar(hidden: true) - .onAppear { - locationsHandler.startHeadingUpdates() - locationsHandler.startLocationUpdates() - } - .onDisappear { - locationsHandler.stopHeadingUpdates() - locationsHandler.stopLocationUpdates() - } - .navigationTitle("Compass") } + .statusBar(hidden: true) + .onAppear { + locationsHandler.startHeadingUpdates() + locationsHandler.startLocationUpdates() + } + .onDisappear { + locationsHandler.stopHeadingUpdates() + locationsHandler.stopLocationUpdates() + } + .navigationTitle("Compass") + .toolbarColorScheme(.dark, for: .navigationBar) } } + + private func headingText() -> String { + let h = Int(locationsHandler.heading.rounded()) % 360 + return "\(h)Β°" + } +} + +// MARK: - Compass Tick Mark +struct CompassTickMark: View { + let degree: Double + let radius: CGFloat + + var body: some View { + let isCardinal = degree.truncatingRemainder(dividingBy: 90) == 0 + let isMajor = degree.truncatingRemainder(dividingBy: 30) == 0 + let isMinor = degree.truncatingRemainder(dividingBy: 10) == 0 + + let length: CGFloat = isCardinal ? 16 : (isMajor ? 12 : (isMinor ? 8 : 4)) + let width: CGFloat = isCardinal ? 2.5 : (isMajor ? 1.5 : 1) + let tickColor: Color = isCardinal ? .white : (isMajor ? .white.opacity(0.7) : .white.opacity(0.3)) + + // Only draw ticks at 2Β° intervals + if Int(degree) % 2 == 0 { + Capsule() + .fill(tickColor) + .frame(width: width, height: length) + .offset(y: -(radius - length / 2)) + .rotationEffect(.degrees(degree)) + } + } +} + +// MARK: - North Indicator +struct CompassNorthIndicator: View { + let radius: CGFloat + + var body: some View { + Triangle() + .fill(Color.orange) + .frame(width: 12, height: 10) + .offset(y: -(radius + 8)) + } +} + +struct Triangle: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + +// MARK: - Compass Label Model & View +struct CompassLabel { + let degrees: Double + let text: String + let isCardinal: Bool + + static let allLabels: [CompassLabel] = [ + CompassLabel(degrees: 0, text: "N", isCardinal: true), + CompassLabel(degrees: 45, text: "NE", isCardinal: false), + CompassLabel(degrees: 90, text: "E", isCardinal: true), + CompassLabel(degrees: 135, text: "SE", isCardinal: false), + CompassLabel(degrees: 180, text: "S", isCardinal: true), + CompassLabel(degrees: 225, text: "SW", isCardinal: false), + CompassLabel(degrees: 270, text: "W", isCardinal: true), + CompassLabel(degrees: 315, text: "NW", isCardinal: false) + ] +} + +struct CompassLabelView: View { + let label: CompassLabel + let radius: CGFloat + + var body: some View { + Text(label.text) + .font(.system(size: label.isCardinal ? 18 : 13, + weight: label.isCardinal ? .bold : .medium)) + .foregroundColor(label.degrees == 0 ? .orange : .white) + .rotationEffect(.degrees(-label.degrees)) + .offset(y: -radius) + .rotationEffect(.degrees(label.degrees)) + } } // MARK: - Waypoint Marker View struct WaypointMarkerView: View { let bearing: Double - let compassDegrees: Double + let radius: CGFloat let color: Color var body: some View { - Circle() - .frame(width: 20, height: 20) - .foregroundColor(color) - .offset(y: -170) - .rotationEffect(Angle(degrees: bearing)) - } + ZStack { + // Outer glow + Image(systemName: "arrowtriangle.up.fill") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(color.opacity(0.3)) + .offset(y: -(radius + 4)) + .rotationEffect(.degrees(bearing)) + // Arrow + Image(systemName: "arrowtriangle.up.fill") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(color) + .offset(y: -(radius + 5)) + .rotationEffect(.degrees(bearing)) + } + } } // MARK: - Bearing Calculator @@ -199,78 +322,6 @@ struct BearingCalculator { } } -// MARK: - Marker Model -struct Marker: Hashable { - let degrees: Double - let label: String - - init(degrees: Double, label: String = "") { - self.degrees = degrees - self.label = label - } - - func degreeText() -> String { - return String(format: "%.0f", self.degrees) - } - - static func markers() -> [Marker] { - return [ - Marker(degrees: 0, label: "N"), - Marker(degrees: 30), - Marker(degrees: 60), - Marker(degrees: 90, label: "E"), - Marker(degrees: 120), - Marker(degrees: 150), - Marker(degrees: 180, label: "S"), - Marker(degrees: 210), - Marker(degrees: 240), - Marker(degrees: 270, label: "W"), - Marker(degrees: 300), - Marker(degrees: 330) - ] - } -} - -// MARK: - Compass Marker View -struct CompassMarkerView: View { - let marker: Marker - let compassDegrees: Double - - var body: some View { - VStack { - Text(marker.degreeText()) - .fontWeight(.light) - .rotationEffect(textAngle()) - - Capsule() - .frame(width: capsuleWidth(), height: capsuleHeight()) - .foregroundColor(capsuleColor()) - - Text(marker.label) - .fontWeight(.bold) - .rotationEffect(textAngle()) - .padding(.bottom, 180) - } - .rotationEffect(Angle(degrees: marker.degrees)) - } - - private func capsuleWidth() -> CGFloat { - marker.degrees == 0 ? 7 : 3 - } - - private func capsuleHeight() -> CGFloat { - marker.degrees == 0 ? 45 : 30 - } - - private func capsuleColor() -> Color { - marker.degrees == 0 ? .red : .gray - } - - private func textAngle() -> Angle { - Angle(degrees: -compassDegrees - marker.degrees) - } -} - // MARK: - Preview struct CompassView_Previews: PreviewProvider { static var previews: some View { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift index 6054e4af..1260f4f5 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift @@ -114,24 +114,19 @@ struct WaypointForm: View { HStack { Text("Icon") Spacer() - EmojiOnlyTextField(text: $icon, placeholder: "Select an emoji") + TextField("Select an emoji", text: $icon) + .keyboardType(.emoji) .font(.title) .focused($iconIsFocused) - .onChange(of: icon) { - // If it contains non-emoji characters, clear it - if !icon.onlyEmojis() { - icon = "" - return + .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]) + } } - - // If multiple emojis are entered or pasted, keep only the last one - if icon.count > 1 { - icon = String(icon.suffix(1)) - } - iconIsFocused = false } - - } Toggle(isOn: $expires) { Label("Expires", systemImage: "clock.badge.xmark") From 8978fce1577f143830b0fa32a5e60bebd0e452a0 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 5 Apr 2026 09:08:27 -0700 Subject: [PATCH 07/34] Handle nil emoji --- Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift index 1260f4f5..6c11d1ef 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift @@ -166,10 +166,8 @@ struct WaypointForm: View { newWaypoint.longitudeI = waypoint.longitudeI newWaypoint.name = name.count > 0 ? name : "Dropped Pin" newWaypoint.description_p = description - // Unicode scalar value for the icon emoji string let unicodeScalers = icon.unicodeScalars - // First element as an UInt32 - let unicode = unicodeScalers[unicodeScalers.startIndex].value + let unicode = unicodeScalers.first?.value ?? 128205 newWaypoint.icon = unicode if locked { if lockedTo == 0 { From 21794d004b4dbe70bfad5f8364816ef81cfc6306 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 5 Apr 2026 10:14:25 -0700 Subject: [PATCH 08/34] Compass fixes --- Meshtastic/Views/Helpers/CompassView.swift | 18 ++++++------- .../Nodes/Helpers/Map/PositionPopover.swift | 26 ++++++++++++------- .../Views/Nodes/Helpers/NodeDetail.swift | 16 +++++++----- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/Meshtastic/Views/Helpers/CompassView.swift b/Meshtastic/Views/Helpers/CompassView.swift index c8c0b9bc..46caa40d 100644 --- a/Meshtastic/Views/Helpers/CompassView.swift +++ b/Meshtastic/Views/Helpers/CompassView.swift @@ -78,13 +78,11 @@ struct CompassView: View { var body: some View { NavigationStack { ZStack { - Color.black.ignoresSafeArea() - VStack(spacing: 0) { // Top fixed heading indicator triangle Image(systemName: "triangle.fill") .font(.system(size: 14, weight: .bold)) - .foregroundColor(.white) + .foregroundColor(.primary) .rotationEffect(.degrees(180)) .padding(.bottom, 4) @@ -92,7 +90,7 @@ struct CompassView: View { ZStack { // Outer bezel ring Circle() - .stroke(Color.white.opacity(0.2), lineWidth: 1.5) + .stroke(Color.primary.opacity(0.2), lineWidth: 1.5) .frame(width: dialRadius * 2 + 20, height: dialRadius * 2 + 20) // Tick marks @@ -109,11 +107,11 @@ struct CompassView: View { // North triangle indicator at 0Β° CompassNorthIndicator(radius: dialRadius + 2) - // Degree readout at center + // Degree readout at center (counter-rotate to stay fixed) VStack(spacing: 4) { Text(headingText()) .font(.system(size: 42, weight: .light, design: .rounded)) - .foregroundColor(.white) + .foregroundColor(.primary) .monospacedDigit() if let distance = distanceToWaypoint() { @@ -128,6 +126,7 @@ struct CompassView: View { .foregroundColor(color.opacity(0.8)) } } + .rotationEffect(Angle(degrees: locationsHandler.heading)) // Waypoint bearing indicator if let bearing = bearingToWaypoint() { @@ -153,7 +152,7 @@ struct CompassView: View { Text("\(String(format: "%.4f", wp.latitude)), \(String(format: "%.4f", wp.longitude))") .font(.system(size: 12, design: .monospaced)) } - .foregroundColor(.white.opacity(0.5)) + .foregroundColor(.secondary) if let bearing = bearingToWaypoint() { HStack(spacing: 4) { @@ -180,7 +179,6 @@ struct CompassView: View { locationsHandler.stopLocationUpdates() } .navigationTitle("Compass") - .toolbarColorScheme(.dark, for: .navigationBar) } } @@ -202,7 +200,7 @@ struct CompassTickMark: View { let length: CGFloat = isCardinal ? 16 : (isMajor ? 12 : (isMinor ? 8 : 4)) let width: CGFloat = isCardinal ? 2.5 : (isMajor ? 1.5 : 1) - let tickColor: Color = isCardinal ? .white : (isMajor ? .white.opacity(0.7) : .white.opacity(0.3)) + let tickColor: Color = isCardinal ? .primary : (isMajor ? .primary.opacity(0.7) : .primary.opacity(0.3)) // Only draw ticks at 2Β° intervals if Int(degree) % 2 == 0 { @@ -264,7 +262,7 @@ struct CompassLabelView: View { Text(label.text) .font(.system(size: label.isCardinal ? 18 : 13, weight: label.isCardinal ? .bold : .medium)) - .foregroundColor(label.degrees == 0 ? .orange : .white) + .foregroundColor(label.degrees == 0 ? .orange : .primary) .rotationEffect(.degrees(-label.degrees)) .offset(y: -radius) .rotationEffect(.degrees(label.degrees)) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift index 8ab7b29f..9ad7a2a2 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift @@ -45,18 +45,23 @@ struct PositionPopover: View { Divider() HStack(alignment: .center) { VStack(alignment: .leading) { - Button { - detentSelection = .large - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - navigateToCompass = true - } - } label: { - HStack { - Image(systemName: "safari") - Text("Open Compass") + if position.isPreciseLocation { + Button { + detentSelection = .large + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + navigateToCompass = true + } + } label: { + Label { + Text("Open Compass") + } icon: { + Image(systemName: "safari") + .symbolRenderingMode(.hierarchical) + .frame(width: 35) + } } + .padding(.bottom, 5) } - .padding(.bottom, 5) /// Time Label { @@ -174,6 +179,7 @@ struct PositionPopover: View { .symbolRenderingMode(.hierarchical) .frame(width: 35) } + .padding(.bottom, 5) } } /// Speed diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index 660e57bd..f4f4a929 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -479,13 +479,15 @@ struct NodeDetail: View { } if node.hasPositions { #if !targetEnvironment(macCatalyst) - Button { - showingCompassSheet = true - } label: { - Label { - Text("Open Compass") - } icon: { - Image(systemName: "safari") + if node.latestPosition?.isPreciseLocation == true { + Button { + showingCompassSheet = true + } label: { + Label { + Text("Open Compass") + } icon: { + Image(systemName: "safari") + } } } #endif From bac376edcb846c9d99fe9770af0e89f1aadfe371 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:40:54 -0700 Subject: [PATCH 09/34] Implement map legend overlay (#1653) * Add map legend feature (issue #924) Implement a map legend overlay accessible from both the mesh map and node detail map views. The legend explains all visual map elements including: - Online/offline node markers with pulsing animation - Detection sensor nodes - Waypoints - Position precision circles - Position history points and heading arrows - Route start/end markers and route lines - Convex hull mesh coverage outline A new "map" button is added to the floating control buttons on both map views, opening the legend as a sheet. Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/23f75e1e-549b-46a1-84c9-fb0a6375dcd9 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve legend descriptions for online/offline nodes Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/23f75e1e-549b-46a1-84c9-fb0a6375dcd9 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * map button glass and cleanup * Update Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update incorect online timeframe * Update Meshtastic/Views/Nodes/MeshMap.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * translation file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen Co-authored-by: Garth Vander Houwen Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Localizable.xcstrings | 110 +++++++ Meshtastic.xcodeproj/project.pbxproj | 4 + Meshtastic/Extensions/View.swift | 12 + .../Views/Nodes/Helpers/Map/MapLegend.swift | 277 ++++++++++++++++++ .../Nodes/Helpers/Map/NodeMapSwiftUI.swift | 34 ++- Meshtastic/Views/Nodes/MeshMap.swift | 23 +- Meshtastic/Views/Settings/RouteRecorder.swift | 3 +- Meshtastic/Views/Settings/Routes.swift | 2 +- 8 files changed, 446 insertions(+), 19 deletions(-) create mode 100644 Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index e8c21ff6..e93f67a7 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3118,6 +3118,13 @@ } } } + }, + "A previous position report for this node." : { + "comment" : "A description of a position history point.", + "isCommentAutoGenerated" : true + }, + "A previous position report showing the direction of travel." : { + }, "A red open lock means the channel is not securely encrypted and is used for precise location data, it uses either no key at all or a 1 byte known key." : { "localizations" : { @@ -3163,6 +3170,10 @@ } } }, + "A shared point of interest. Long-press the map to create one." : { + "comment" : "A description of a waypoint.", + "isCommentAutoGenerated" : true + }, "A Trace Route was sent, no response has been received." : { "localizations" : { "da" : { @@ -6009,6 +6020,10 @@ } } }, + "An outline enclosing all LoRa node positions on the mesh." : { + "comment" : "A description of the convex hull of a mesh.", + "isCommentAutoGenerated" : true + }, "Any missed messages will be delivered again." : { "localizations" : { "da" : { @@ -14154,6 +14169,10 @@ } } }, + "Dashed line showing a recorded route path." : { + "comment" : "A description of a dashed line that shows a recorded route path.", + "isCommentAutoGenerated" : true + }, "Date" : { "localizations" : { "da" : { @@ -25286,6 +25305,16 @@ } } } + }, + "Hide legend" : { + "comment" : "A label for a button that hides the map legend.", + "isCommentAutoGenerated" : true + }, + "Hide map legend" : { + + }, + "Hides the map legend." : { + }, "HIGH" : { "localizations" : { @@ -27578,6 +27607,10 @@ } } }, + "Indicates reduced GPS precision. The node is somewhere within the shaded area." : { + "comment" : "A description of a position precision circle.", + "isCommentAutoGenerated" : true + }, "Indoor Air Quality" : { "extractionState" : "stale", "localizations" : { @@ -30697,6 +30730,10 @@ } } }, + "Map Legend" : { + "comment" : "A title for a view that displays a map legend.", + "isCommentAutoGenerated" : true + }, "Map Options" : { "localizations" : { "da" : { @@ -31159,6 +31196,10 @@ } } }, + "Mesh Coverage" : { + "comment" : "A heading for the convex hull of the mesh.", + "isCommentAutoGenerated" : true + }, "Mesh Live Activity" : { "localizations" : { "da" : { @@ -35253,6 +35294,10 @@ } } }, + "Node heard within the last 2 hours. Shown with a pulsing ring on the map." : { + "comment" : "A description of the pulsing ring on the map.", + "isCommentAutoGenerated" : true + }, "Node History" : { "localizations" : { "da" : { @@ -35404,6 +35449,10 @@ } } }, + "Node not heard recently. Shown without a pulsing ring on the map." : { + "comment" : "A description of a node that is not heard by the user.", + "isCommentAutoGenerated" : true + }, "Node Number" : { "localizations" : { "da" : { @@ -35456,6 +35505,10 @@ } } }, + "Node with an active detection sensor module." : { + "comment" : "A description of a sensor node.", + "isCommentAutoGenerated" : true + }, "Nodes" : { "localizations" : { "da" : { @@ -36190,6 +36243,10 @@ } } }, + "Offline Node" : { + "comment" : "A description of an offline node.", + "isCommentAutoGenerated" : true + }, "Ok" : { "localizations" : { "es" : { @@ -36808,6 +36865,10 @@ } } }, + "Online Node" : { + "comment" : "A label displayed in the map legend for an online node.", + "isCommentAutoGenerated" : true + }, "Only permitted for SENSOR, TRACKER and TAK_TRACKER roles, this will inhibit all rebroadcasts, not unlike CLIENT_MUTE role." : { "extractionState" : "stale", "localizations" : { @@ -39546,6 +39607,14 @@ } } }, + "Position History" : { + "comment" : "A heading for the position history of a node.", + "isCommentAutoGenerated" : true + }, + "Position History Point" : { + "comment" : "A label displayed in the map legend for a position history point.", + "isCommentAutoGenerated" : true + }, "Position Log" : { "localizations" : { "da" : { @@ -39684,6 +39753,13 @@ } } }, + "Position Precision" : { + + }, + "Position Precision Circle" : { + "comment" : "A description of a map element that indicates reduced GPS precision.", + "isCommentAutoGenerated" : true + }, "Position Sent" : { "localizations" : { "da" : { @@ -39736,6 +39812,10 @@ } } }, + "Position with Heading" : { + "comment" : "A description of a position history point that shows the direction of travel.", + "isCommentAutoGenerated" : true + }, "Positions Enabled" : { "localizations" : { "da" : { @@ -44715,6 +44795,14 @@ } } }, + "Route End" : { + "comment" : "A label for a route end point on a map.", + "isCommentAutoGenerated" : true + }, + "Route Line" : { + "comment" : "A description of a route line on a map.", + "isCommentAutoGenerated" : true + }, "Route Lines" : { "localizations" : { "da" : { @@ -44893,6 +44981,10 @@ } } }, + "Route Start" : { + "comment" : "A label displayed for the start of a route.", + "isCommentAutoGenerated" : true + }, "Route: %@" : { "localizations" : { "da" : { @@ -50389,6 +50481,13 @@ } } } + }, + "Show legend" : { + "comment" : "A label for a button that shows/hides the map legend.", + "isCommentAutoGenerated" : true + }, + "Show map legend" : { + }, "Show nodes" : { "localizations" : { @@ -50608,6 +50707,9 @@ } } } + }, + "Shows the map legend." : { + }, "Shut Down" : { "localizations" : { @@ -56164,6 +56266,10 @@ } } }, + "Toggles the map legend" : { + "comment" : "A hint for the user to toggle the map legend.", + "isCommentAutoGenerated" : true + }, "Topic: %@" : { "extractionState" : "stale", "localizations" : { @@ -60865,6 +60971,10 @@ } } }, + "Waypoint" : { + "comment" : "A caption displayed underneath the name of a waypoint.", + "isCommentAutoGenerated" : true + }, "Waypoint Failed to Send" : { "localizations" : { "es" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 5038f261..79842eda 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -216,6 +216,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 */; }; + DD09240001E7FAD600E70001 /* MapLegend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD09240002E7FAD600E70001 /* MapLegend.swift */; }; DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */; }; DD964FC62975DBFD007C176F /* QueryCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC52975DBFD007C176F /* QueryCoreData.swift */; }; DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD97E96528EFD9820056DDA4 /* MeshtasticLogo.swift */; }; @@ -579,6 +580,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 = ""; }; + DD09240002E7FAD600E70001 /* MapLegend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLegend.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 /* QueryCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryCoreData.swift; sourceTree = ""; }; @@ -1181,6 +1183,7 @@ children = ( DDDC22362BA9232C002C44F1 /* MapContent */, 3D3417D32E2DC293006A988B /* MapDataFiles.swift */, + DD09240002E7FAD600E70001 /* MapLegend.swift */, DD94B73F2ACCE3BE00DCD1D1 /* MapSettingsForm.swift */, DDB6CCFA2AAF805100945AF6 /* NodeMapSwiftUI.swift */, DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */, @@ -1804,6 +1807,7 @@ DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */, DDB6ABDB28B0AC6000384BA1 /* DistanceText.swift in Sources */, DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */, + DD09240001E7FAD600E70001 /* MapLegend.swift in Sources */, DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */, 23AD546B2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift in Sources */, 23F488122E32980B002C776F /* AccessoryManager+Position.swift in Sources */, diff --git a/Meshtastic/Extensions/View.swift b/Meshtastic/Extensions/View.swift index ec27882d..b28f5e8e 100644 --- a/Meshtastic/Extensions/View.swift +++ b/Meshtastic/Extensions/View.swift @@ -46,6 +46,18 @@ extension View { self } } + + @ViewBuilder + func glassButtonStyle() -> some View { + if #available(iOS 26.0, macOS 26.0, *) { + self.buttonStyle(.glass) + } else { + self + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + } + } } private struct FirstAppear: ViewModifier { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift new file mode 100644 index 00000000..fd142007 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift @@ -0,0 +1,277 @@ +// +// MapLegend.swift +// Meshtastic +// +// Implements a map legend overlay that explains the visual elements +// displayed on the map (issue #924). +// + +import SwiftUI + +struct MapLegendItem: View { + let symbol: AnyView + let title: String + let subtitle: String? + + init(symbol: AnyView, title: String, subtitle: String? = nil) { + self.symbol = symbol + self.title = title + self.subtitle = subtitle + } + + var body: some View { + HStack(spacing: 12) { + symbol + .frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline) + .fontWeight(.medium) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + } +} + +struct MapLegend: View { + @Environment(\.dismiss) private var dismiss + let isMeshMap: Bool + + var body: some View { + NavigationStack { + List { + nodeSection + if isMeshMap { + waypointSection + } + precisionSection + if !isMeshMap { + historySection + } + routeSection + if isMeshMap { + convexHullSection + } + } + .navigationTitle("Map Legend") + .navigationBarTitleDisplayMode(.inline) + } + } + + // MARK: - Sections + + private var nodeSection: some View { + Section { + MapLegendItem( + symbol: AnyView(onlineNodeSymbol), + title: String(localized: "Online Node"), + subtitle: String(localized: "Node heard within the last 2 hours. Shown with a pulsing ring on the map.") + ) + MapLegendItem( + symbol: AnyView(offlineNodeSymbol), + title: String(localized: "Offline Node"), + subtitle: String(localized: "Node not heard recently. Shown without a pulsing ring on the map.") + ) + MapLegendItem( + symbol: AnyView(sensorNodeSymbol), + title: String(localized: "Detection Sensor"), + subtitle: String(localized: "Node with an active detection sensor module.") + ) + } header: { + Text("Nodes") + } + } + + private var waypointSection: some View { + Section { + MapLegendItem( + symbol: AnyView(waypointSymbol), + title: String(localized: "Waypoint"), + subtitle: String(localized: "A shared point of interest. Long-press the map to create one.") + ) + } header: { + Text("Waypoints") + } + } + + private var precisionSection: some View { + Section { + MapLegendItem( + symbol: AnyView(precisionCircleSymbol), + title: String(localized: "Position Precision Circle"), + subtitle: String(localized: "Indicates reduced GPS precision. The node is somewhere within the shaded area.") + ) + } header: { + Text("Position Precision") + } + } + + private var historySection: some View { + Section { + MapLegendItem( + symbol: AnyView(historyPointSymbol), + title: String(localized: "Position History Point"), + subtitle: String(localized: "A previous position report for this node.") + ) + MapLegendItem( + symbol: AnyView(historyArrowSymbol), + title: String(localized: "Position with Heading"), + subtitle: String(localized: "A previous position report showing the direction of travel.") + ) + } header: { + Text("Position History") + } + } + + private var routeSection: some View { + Section { + MapLegendItem( + symbol: AnyView(routeStartSymbol), + title: String(localized: "Route Start"), + subtitle: nil + ) + MapLegendItem( + symbol: AnyView(routeEndSymbol), + title: String(localized: "Route End"), + subtitle: nil + ) + MapLegendItem( + symbol: AnyView(routeLineSymbol), + title: String(localized: "Route Line"), + subtitle: String(localized: "Dashed line showing a recorded route path.") + ) + } header: { + Text("Routes") + } + } + + private var convexHullSection: some View { + Section { + MapLegendItem( + symbol: AnyView(convexHullSymbol), + title: String(localized: "Convex Hull"), + subtitle: String(localized: "An outline enclosing all LoRa node positions on the mesh.") + ) + } header: { + Text("Mesh Coverage") + } + } + + // MARK: - Symbols + + private var onlineNodeSymbol: some View { + ZStack { + Circle() + .fill(Color.green.opacity(0.3)) + .frame(width: 38, height: 38) + CircleText(text: "ON", color: .green, circleSize: 28) + } + } + + private var offlineNodeSymbol: some View { + CircleText(text: "OFF", color: .gray, circleSize: 28) + } + + private var sensorNodeSymbol: some View { + ZStack { + Circle() + .fill(Color.blue) + .frame(width: 28, height: 28) + Image(systemName: "sensor.fill") + .font(.system(size: 14)) + .foregroundStyle(.white) + } + } + + private var waypointSymbol: some View { + CircleText(text: "πŸ“", color: .orange, circleSize: 28) + } + + private var precisionCircleSymbol: some View { + ZStack { + Circle() + .fill(Color.blue.opacity(0.25)) + .frame(width: 36, height: 36) + Circle() + .strokeBorder(Color.white, lineWidth: 1) + .frame(width: 36, height: 36) + Circle() + .fill(Color.blue) + .frame(width: 8, height: 8) + } + } + + private var historyPointSymbol: some View { + ZStack { + Circle() + .fill(Color.blue) + .frame(width: 12, height: 12) + Circle() + .stroke(Color.primary, lineWidth: 1) + .frame(width: 12, height: 12) + } + } + + private var historyArrowSymbol: some View { + Image(systemName: "location.north.circle.fill") + .font(.system(size: 20)) + .foregroundStyle(.blue) + } + + private var routeStartSymbol: some View { + Circle() + .fill(Color.green) + .strokeBorder(Color.white, lineWidth: 2) + .frame(width: 15, height: 15) + } + + private var routeEndSymbol: some View { + Circle() + .fill(Color.black) + .strokeBorder(Color.white, lineWidth: 2) + .frame(width: 15, height: 15) + } + + private var routeLineSymbol: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 4, y: 20)) + path.addLine(to: CGPoint(x: 36, y: 20)) + } + .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [6, 6])) + .foregroundStyle(Color.blue) + } + } + + private var convexHullSymbol: some View { + ZStack { + // Draw a simplified polygon shape + ConvexHullShape() + .fill(Color.indigo.opacity(0.4)) + ConvexHullShape() + .stroke(Color.blue, lineWidth: 2) + } + .frame(width: 32, height: 32) + } +} + +private struct ConvexHullShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let w = rect.width + let h = rect.height + path.move(to: CGPoint(x: w * 0.5, y: h * 0.1)) + path.addLine(to: CGPoint(x: w * 0.85, y: h * 0.3)) + path.addLine(to: CGPoint(x: w * 0.9, y: h * 0.7)) + path.addLine(to: CGPoint(x: w * 0.6, y: h * 0.9)) + path.addLine(to: CGPoint(x: w * 0.15, y: h * 0.8)) + path.addLine(to: CGPoint(x: w * 0.1, y: h * 0.35)) + path.closeSubpath() + return path + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift index 5181586c..09a08e34 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift @@ -52,6 +52,7 @@ struct NodeMapSwiftUI: View { @State var isLookingAround = false @State var isShowingAltitude = false @State var isEditingSettings = false + @State var isShowingLegend = false @State var isMeshMap = false @State var enabledOverlayConfigs: Set = Set() @@ -97,6 +98,13 @@ struct NodeMapSwiftUI: View { .sheet(isPresented: $isEditingSettings) { MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs) } + .sheet(isPresented: $isShowingLegend) { + MapLegend(isMeshMap: false) + .presentationDetents([.medium, .large]) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } .onChange(of: selectedMapLayer) { _, newMapLayer in updateMapStyle(for: newMapLayer) } @@ -168,17 +176,25 @@ struct NodeMapSwiftUI: View { private var controlButtons: some View { HStack { + Button(action: { + withAnimation { + isShowingLegend = !isShowingLegend + } + }) { + Image(systemName: isShowingLegend ? "map.fill" : "map") + } + .accessibilityLabel(isShowingLegend ? Text("Hide legend") : Text("Show legend")) + .accessibilityHint(Text("Toggles the map legend")) + .glassButtonStyle() + Button(action: { withAnimation { isEditingSettings = !isEditingSettings } }) { Image(systemName: isEditingSettings ? "info.circle.fill" : "info.circle") - .padding(.vertical, 5) } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) + .glassButtonStyle() if scene != nil { Button(action: { @@ -188,11 +204,8 @@ struct NodeMapSwiftUI: View { isLookingAround = !isLookingAround }) { Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars") - .padding(.vertical, 5) } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) + .glassButtonStyle() } if node.positions?.count ?? 0 > 1 { @@ -203,11 +216,8 @@ struct NodeMapSwiftUI: View { isShowingAltitude = !isShowingAltitude }) { Image(systemName: isShowingAltitude ? "mountain.2.fill" : "mountain.2") - .padding(.vertical, 5) } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) + .glassButtonStyle() } } .controlSize(.regular) diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index 6414eb3f..5ace2510 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -41,6 +41,7 @@ struct MeshMap: View { @State var selectedWaypointId: String? @State var newWaypointCoord: CLLocationCoordinate2D? @State var isMeshMap = true + @State private var showLegend = false /// Filter @StateObject var filters = NodeFilterParameters() @@ -158,20 +159,34 @@ struct MeshMap: View { filters: filters ) } + .sheet(isPresented: $showLegend) { + MapLegend(isMeshMap: true) + .presentationDetents([.medium, .large]) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } .safeAreaInset(edge: .bottom, alignment: .trailing) { HStack { Spacer() + Button(action: { + withAnimation { + showLegend = !showLegend + } + }) { + Image(systemName: showLegend ? "map.fill" : "map") + } + .accessibilityLabel(showLegend ? "Hide map legend" : "Show map legend") + .accessibilityHint(showLegend ? "Hides the map legend." : "Shows the map legend.") + .glassButtonStyle() Button(action: { withAnimation { editingSettings = !editingSettings } }) { Image(systemName: editingSettings ? "info.circle.fill" : "info.circle") - .padding(.vertical, 5) } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) + .glassButtonStyle() } .controlSize(.regular) .padding(5) diff --git a/Meshtastic/Views/Settings/RouteRecorder.swift b/Meshtastic/Views/Settings/RouteRecorder.swift index 6a0fc539..b1c63bc2 100644 --- a/Meshtastic/Views/Settings/RouteRecorder.swift +++ b/Meshtastic/Views/Settings/RouteRecorder.swift @@ -74,8 +74,7 @@ struct RouteRecorder: View { .symbolRenderingMode(.multicolor) .foregroundColor(.red) } - .buttonStyle(.bordered) - .foregroundColor(.red) + .glassButtonStyle() .buttonBorderShape(.circle) .matchedGeometryEffect(id: "Details Button", in: namespace) diff --git a/Meshtastic/Views/Settings/Routes.swift b/Meshtastic/Views/Settings/Routes.swift index a90533df..3db1cc4f 100644 --- a/Meshtastic/Views/Settings/Routes.swift +++ b/Meshtastic/Views/Settings/Routes.swift @@ -281,7 +281,7 @@ struct Routes: View { } label: { Label("Export", systemImage: "square.and.arrow.down") } - .buttonStyle(.bordered) + .glassButtonStyle() .buttonBorderShape(.capsule) .controlSize(.large) .padding(.bottom) From 7e4fb0a15482f863bedc2377e46217bdf346ce87 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 5 Apr 2026 14:57:16 -0700 Subject: [PATCH 10/34] fix compass labels --- Meshtastic/Views/Helpers/CompassView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Meshtastic/Views/Helpers/CompassView.swift b/Meshtastic/Views/Helpers/CompassView.swift index 46caa40d..c6844fc3 100644 --- a/Meshtastic/Views/Helpers/CompassView.swift +++ b/Meshtastic/Views/Helpers/CompassView.swift @@ -100,8 +100,7 @@ struct CompassView: View { // Cardinal and intercardinal labels ForEach(CompassLabel.allLabels, id: \.degrees) { label in - CompassLabelView(label: label, radius: dialRadius - 28) - .rotationEffect(.degrees(-locationsHandler.heading)) + CompassLabelView(label: label, radius: dialRadius - 28, heading: locationsHandler.heading) } // North triangle indicator at 0Β° @@ -257,13 +256,14 @@ struct CompassLabel { struct CompassLabelView: View { let label: CompassLabel let radius: CGFloat + let heading: Double var body: some View { Text(label.text) .font(.system(size: label.isCardinal ? 18 : 13, weight: label.isCardinal ? .bold : .medium)) .foregroundColor(label.degrees == 0 ? .orange : .primary) - .rotationEffect(.degrees(-label.degrees)) + .rotationEffect(.degrees(-label.degrees + heading)) .offset(y: -radius) .rotationEffect(.degrees(label.degrees)) } From d493a244c805ad7de518739097b29d195f4f38d6 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 5 Apr 2026 16:34:38 -0700 Subject: [PATCH 11/34] Fix compass ugly --- Localizable.xcstrings | 1 - Meshtastic/Views/Helpers/CompassView.swift | 91 ++++++++++++------- .../Nodes/Helpers/Map/PositionPopover.swift | 3 +- .../Views/Nodes/Helpers/NodeDetail.swift | 2 +- 4 files changed, 61 insertions(+), 36 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index e93f67a7..e585534f 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -752,7 +752,6 @@ "shouldTranslate" : false }, "%@ %@" : { - "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { diff --git a/Meshtastic/Views/Helpers/CompassView.swift b/Meshtastic/Views/Helpers/CompassView.swift index c6844fc3..d0e63939 100644 --- a/Meshtastic/Views/Helpers/CompassView.swift +++ b/Meshtastic/Views/Helpers/CompassView.swift @@ -13,9 +13,8 @@ struct CompassView: View { /// Single waypoint parameter let waypointLocation: CLLocationCoordinate2D? - - let waypointName: String? - + let waypointLongName: String? + let waypointShortName: String? let color: Color @ObservedObject private var locationsHandler = LocationsHandler.shared @@ -79,6 +78,27 @@ struct CompassView: View { NavigationStack { ZStack { VStack(spacing: 0) { + + if waypointLongName != nil || waypointLocation != nil { + Spacer() + VStack(spacing: 4) { + Text(waypointLongName ?? "Waypoint") + .font(.largeTitle) + + if let bearing = bearingToWaypoint() { + HStack(spacing: 4) { + Image(systemName: "location.north.fill") + .font(.title2) + .rotationEffect(.degrees(bearing)) + Text("\(String(format: "%.0fΒ°", bearing))") + .font(.title2) + } + .foregroundColor(.secondary) + } + } + .padding(.bottom, 8) + } + Spacer() // Top fixed heading indicator triangle Image(systemName: "triangle.fill") .font(.system(size: 14, weight: .bold)) @@ -86,6 +106,7 @@ struct CompassView: View { .rotationEffect(.degrees(180)) .padding(.bottom, 4) + // Rotating compass dial ZStack { // Outer bezel ring @@ -107,22 +128,35 @@ struct CompassView: View { CompassNorthIndicator(radius: dialRadius + 2) // Degree readout at center (counter-rotate to stay fixed) - VStack(spacing: 4) { - Text(headingText()) - .font(.system(size: 42, weight: .light, design: .rounded)) - .foregroundColor(.primary) - .monospacedDigit() + ZStack { + let textColor = color.isLight() ? Color.black : Color.white - if let distance = distanceToWaypoint() { - Text(formatDistance(distance)) - .font(.system(size: 18, weight: .semibold, design: .rounded)) - .foregroundColor(color) - } + Circle() + .fill(color) + .overlay( + Circle().stroke(textColor.opacity(0.75), lineWidth: 4) + ) + .frame(width: 172, height: 172) + + VStack(spacing: 4) { + Text(headingText()) + .font(.system(size: 42, weight: .light, design: .rounded)) + .foregroundColor(textColor) + .monospacedDigit() + + if let distance = distanceToWaypoint() { + Text(formatDistance(distance)) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundColor(textColor.opacity(0.9)) + + } + + if waypointShortName != nil || waypointLocation != nil { + Text(waypointShortName ?? "WP") + .font(.title3) + .foregroundColor(textColor.opacity(0.75)) + } - if waypointName != nil || waypointLocation != nil { - Text(waypointName ?? "Waypoint") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(color.opacity(0.8)) } } .rotationEffect(Angle(degrees: locationsHandler.heading)) @@ -141,30 +175,20 @@ struct CompassView: View { } .frame(width: dialRadius * 2 + 40, height: dialRadius * 2 + 40) .rotationEffect(Angle(degrees: -locationsHandler.heading)) - + Spacer() // Bottom info if let wp = waypointLocation { VStack(spacing: 6) { HStack(spacing: 4) { Image(systemName: "mappin") - .font(.system(size: 11)) - Text("\(String(format: "%.4f", wp.latitude)), \(String(format: "%.4f", wp.longitude))") - .font(.system(size: 12, design: .monospaced)) + .font(.title2) + Text("\(String(format: "%.4f", wp.latitude)) \(String(format: "%.4f", wp.longitude))") + .font(.title3) } .foregroundColor(.secondary) - if let bearing = bearingToWaypoint() { - HStack(spacing: 4) { - Image(systemName: "location.north.fill") - .font(.system(size: 11)) - .rotationEffect(.degrees(bearing)) - Text("\(String(format: "%.0fΒ°", bearing))") - .font(.system(size: 12, weight: .medium, design: .monospaced)) - } - .foregroundColor(color.opacity(0.7)) - } } - .padding(.top, 20) + Spacer() } } } @@ -325,7 +349,8 @@ struct CompassView_Previews: PreviewProvider { static var previews: some View { CompassView( waypointLocation: CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090), - waypointName: "Apple Park", + waypointLongName: "Apple Park", + waypointShortName: "", color: Color.orange ) } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift index 9ad7a2a2..bc40ddf3 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift @@ -264,7 +264,8 @@ struct PositionPopover: View { .navigationDestination(isPresented: $navigateToCompass) { CompassView( waypointLocation: position.coordinate, - waypointName: position.nodePosition?.user?.longName ?? "Unknown node", + waypointLongName: position.nodePosition?.user?.longName ?? "Unknown node", + waypointShortName: position.nodePosition?.user?.shortName ?? "???", color: (position.nodePosition?.user?.num != nil && position.nodePosition?.user?.num != 0) ? Color(UIColor(hex: UInt32(position.nodePosition!.user!.num))) : .orange ) } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index f4f4a929..fcfad87d 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -578,7 +578,7 @@ struct NodeDetail: View { } } .sheet(isPresented: $showingCompassSheet) { - CompassView(waypointLocation: node.latestPosition?.nodeCoordinate ?? nil, waypointName: node.user?.longName ?? nil, color: Color(UIColor(hex: UInt32(node.num)))) + CompassView(waypointLocation: node.latestPosition?.nodeCoordinate ?? nil, waypointLongName: node.user?.longName ?? nil, waypointShortName: node.user?.shortName ?? nil, color: Color(UIColor(hex: UInt32(node.num)))) } .onAppear { scrollView.scrollTo("topOfList", anchor: .top) From 1f9b4384f76d45a672c831c375392ea60446e6d6 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 5 Apr 2026 17:14:46 -0700 Subject: [PATCH 12/34] Update Meshtastic.xcodeproj/project.pbxproj Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Meshtastic.xcodeproj/project.pbxproj | 1 - 1 file changed, 1 deletion(-) diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 79842eda..bdbff68f 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -103,7 +103,6 @@ 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; }; 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; }; A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; }; - AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; }; ABB99DEB2E2EA1C500CFBD05 /* AppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */; }; B16C760DB291CFAB5335EADB /* TAKCertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */; }; From 67946039bfe3c75c8956fd02f4f46a336a39a55c Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 5 Apr 2026 17:19:31 -0700 Subject: [PATCH 13/34] Remove unnecessary attributes from WaypointEntity Removed 'createdBy' and 'lastUpdatedBy' attributes from WaypointEntity. --- .../MeshtasticDataModelV 55.xcdatamodel/contents | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents index bc7e7d0b..e6be1488 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents @@ -490,12 +490,10 @@ - - @@ -507,4 +505,4 @@ - \ No newline at end of file + From ff7fbda1c7c21edf49912b15c3d9086ec9d04ac1 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 5 Apr 2026 17:56:04 -0700 Subject: [PATCH 14/34] Properly add new waypoint fields --- Meshtastic.xcodeproj/project.pbxproj | 11 +- .../Meshtastic.xcdatamodeld/.xccurrentversion | 2 +- .../contents | 510 ++++++++++++++++++ 3 files changed, 517 insertions(+), 6 deletions(-) create mode 100644 Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index bdbff68f..7dd0ae93 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -82,7 +82,6 @@ 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; }; 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; }; 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; }; - AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; 2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01028778B8BFD81F7A039593 /* TAKConnection.swift */; }; 300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */; }; 3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; }; @@ -103,6 +102,7 @@ 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; }; 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; }; A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; }; + AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; }; ABB99DEB2E2EA1C500CFBD05 /* AppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */; }; B16C760DB291CFAB5335EADB /* TAKCertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */; }; @@ -136,6 +136,7 @@ D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C983A12B79D1A600BDBE6A /* RequestPositionButton.swift */; }; DD007BAE2AA4E91200F5FA12 /* MyInfoEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */; }; DD007BB02AA5981000F5FA12 /* NodeInfoEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD007BAF2AA5981000F5FA12 /* NodeInfoEntityExtension.swift */; }; + DD09240001E7FAD600E70001 /* MapLegend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD09240002E7FAD600E70001 /* MapLegend.swift */; }; DD0BE3102CB9FDC4000BA445 /* DetectionSensorEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0BE30F2CB9FDC4000BA445 /* DetectionSensorEnums.swift */; }; DD0D3D222A55CEB10066DB71 /* CocoaMQTT in Frameworks */ = {isa = PBXBuildFile; productRef = DD0D3D212A55CEB10066DB71 /* CocoaMQTT */; }; DD0E21012B8A6F1300F2D100 /* DeviceHardware.json in Resources */ = {isa = PBXBuildFile; fileRef = DD0E21002B8A6BC500F2D100 /* DeviceHardware.json */; }; @@ -215,7 +216,6 @@ 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 */; }; - DD09240001E7FAD600E70001 /* MapLegend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD09240002E7FAD600E70001 /* MapLegend.swift */; }; DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */; }; DD964FC62975DBFD007C176F /* QueryCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC52975DBFD007C176F /* QueryCoreData.swift */; }; DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD97E96528EFD9820056DDA4 /* MeshtasticLogo.swift */; }; @@ -413,7 +413,6 @@ 25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; - AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = ""; }; 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = ""; }; 3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = ""; }; 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; }; @@ -472,6 +471,7 @@ DD04804A2E9295A5005F946C /* MeshtasticDataModelV 55.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 55.xcdatamodel"; sourceTree = ""; }; DD05296F2B77F454008E44CD /* MeshtasticDataModelV 26.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 26.xcdatamodel"; sourceTree = ""; }; DD0836AB2DE7C7CB00A3A973 /* MeshtasticDataModelV 52.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 52.xcdatamodel"; sourceTree = ""; }; + DD09240002E7FAD600E70001 /* MapLegend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLegend.swift; sourceTree = ""; }; DD0BE30C2CB785D8000BA445 /* MeshtasticDataModelV 46.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 46.xcdatamodel"; sourceTree = ""; }; DD0BE30F2CB9FDC4000BA445 /* DetectionSensorEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorEnums.swift; sourceTree = ""; }; DD0E20FF2B892E1300F2D100 /* MeshtasticDataModelV 28.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 28.xcdatamodel"; sourceTree = ""; }; @@ -564,6 +564,7 @@ DD77093E2AA1B146007A8BF0 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; DD798B062915928D005217CD /* ChannelMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMessageList.swift; sourceTree = ""; }; DD7CF8DA2C93663C008BD10E /* MeshtasticDataModelV 44.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 44.xcdatamodel"; sourceTree = ""; }; + DD7D46262F833B520028AC1A /* MeshtasticDataModelV 56.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 56.xcdatamodel"; sourceTree = ""; }; DD7E235F2C7AA3E50078ACDF /* MeshtasticDataModelV 43.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 43.xcdatamodel"; sourceTree = ""; }; DD8169FE272476C700F4AB02 /* LogDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogDocument.swift; sourceTree = ""; }; DD836AE626F6B38600ABCC23 /* Connect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connect.swift; sourceTree = ""; }; @@ -579,7 +580,6 @@ 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 = ""; }; - DD09240002E7FAD600E70001 /* MapLegend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLegend.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 /* QueryCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryCoreData.swift; sourceTree = ""; }; @@ -2435,6 +2435,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + DD7D46262F833B520028AC1A /* MeshtasticDataModelV 56.xcdatamodel */, DD04804A2E9295A5005F946C /* MeshtasticDataModelV 55.xcdatamodel */, DDDF34392E2CB8E600356DC3 /* MeshtasticDataModelV 54.xcdatamodel */, DD1BEF462DFF284C0090CE24 /* MeshtasticDataModelV 53.xcdatamodel */, @@ -2491,7 +2492,7 @@ DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DD04804A2E9295A5005F946C /* MeshtasticDataModelV 55.xcdatamodel */; + currentVersion = DD7D46262F833B520028AC1A /* MeshtasticDataModelV 56.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion index ef8c7fc9..7f0030db 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion +++ b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - MeshtasticDataModelV 55.xcdatamodel + MeshtasticDataModelV 56.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents new file mode 100644 index 00000000..38fd1242 --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents @@ -0,0 +1,510 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 316fc487378f9b6efcba7683bdae84898532eb6f Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 5 Apr 2026 21:38:10 -0700 Subject: [PATCH 15/34] Message translation (#1656) * Add deep link documentation to README (#1655) Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/df28c94e-7e3d-44fc-8264-6ae1b875fb23 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * message translation core data version to match 2.7.10 --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --- Localizable.xcstrings | 16 +++ .../CoreData/MessageEntityExtension.swift | 18 +++ .../contents | 3 + .../Views/Messages/ChannelMessageRow.swift | 22 ++-- .../Messages/MessageContextMenuItems.swift | 28 ++++- Meshtastic/Views/Messages/MessageText.swift | 104 +++++++++++++++++- .../Views/Messages/UserMessageRow.swift | 20 ++-- Meshtastic/Views/Settings/AppSettings.swift | 2 +- README.md | 76 +++++++++++++ 9 files changed, 263 insertions(+), 26 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index e585534f..1619558a 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -11421,6 +11421,10 @@ } } }, + "Clear Translation" : { + "comment" : "A button", + "isCommentAutoGenerated" : true + }, "Client" : { "extractionState" : "stale", "localizations" : { @@ -50644,6 +50648,14 @@ } } }, + "Show Original" : { + "comment" : "A label for a button that shows the original message instead of the translation.", + "isCommentAutoGenerated" : true + }, + "Show Translation" : { + "comment" : "A label for a button that toggles whether the translation of a message is shown.", + "isCommentAutoGenerated" : true + }, "Show Waypoints" : { "extractionState" : "stale", "localizations" : { @@ -56940,6 +56952,10 @@ } } }, + "Translate" : { + "comment" : "A button to translate a message.", + "isCommentAutoGenerated" : true + }, "Transmit data (txd) GPIO pin" : { "localizations" : { "da" : { diff --git a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift index c9fab38a..a6f232fd 100644 --- a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift @@ -12,6 +12,24 @@ import MapKit import SwiftUI extension MessageEntity { + var hasTranslatedPayload: Bool { + !(messagePayloadTranslated?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + } + + var displayedPayload: String { + if showTranslatedMessage, hasTranslatedPayload { + return messagePayloadTranslated ?? messagePayload ?? "EMPTY MESSAGE" + } + return messagePayload ?? "EMPTY MESSAGE" + } + + var displayedMarkdownPayload: String { + if showTranslatedMessage, hasTranslatedPayload { + return messagePayloadTranslatedMarkdown ?? messagePayloadTranslated ?? messagePayload ?? "EMPTY MESSAGE" + } + return messagePayloadMarkdown ?? messagePayload ?? "EMPTY MESSAGE" + } + var timestamp: Date { let time = messageTimestamp return Date(timeIntervalSince1970: TimeInterval(time)) diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents index 38fd1242..c5760a62 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents @@ -157,6 +157,8 @@ + + @@ -168,6 +170,7 @@ + diff --git a/Meshtastic/Views/Messages/ChannelMessageRow.swift b/Meshtastic/Views/Messages/ChannelMessageRow.swift index eb0f2a9f..8a02a80f 100644 --- a/Meshtastic/Views/Messages/ChannelMessageRow.swift +++ b/Meshtastic/Views/Messages/ChannelMessageRow.swift @@ -23,16 +23,16 @@ struct ChannelMessageRow: View { Int64(preferredPeripheralNum) == message.fromUser?.num } - init(message: MessageEntity, - allMessages: FetchedResults, - previousMessage: MessageEntity?, - preferredPeripheralNum: Int, - channel: ChannelEntity, - replyMessageId: Binding, - messageFieldFocused: FocusState.Binding, - messageToHighlight: Binding, - scrollView: ScrollViewProxy, - onInteractionComplete: @escaping () -> Void) { + init(message: MessageEntity, + allMessages: FetchedResults, + previousMessage: MessageEntity?, + preferredPeripheralNum: Int, + channel: ChannelEntity, + replyMessageId: Binding, + messageFieldFocused: FocusState.Binding, + messageToHighlight: Binding, + scrollView: ScrollViewProxy, + onInteractionComplete: @escaping () -> Void) { // Initialize ObservedObject with the concrete instance self._message = ObservedObject(initialValue: message) self.allMessages = allMessages @@ -80,7 +80,7 @@ struct ChannelMessageRow: View { } } } label: { - Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2) + Text(messageReply?.displayedPayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2) .padding(10) .overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.blue, lineWidth: 0.5)) Image(systemName: "arrowshape.turn.up.left.fill") diff --git a/Meshtastic/Views/Messages/MessageContextMenuItems.swift b/Meshtastic/Views/Messages/MessageContextMenuItems.swift index 8c5a301b..a97f3801 100644 --- a/Meshtastic/Views/Messages/MessageContextMenuItems.swift +++ b/Meshtastic/Views/Messages/MessageContextMenuItems.swift @@ -12,7 +12,13 @@ struct MessageContextMenuItems: View { @Binding var isShowingDeleteConfirmation: Bool @Binding var isShowingTapbackInput: Bool let onReply: () -> Void - @State var relayDisplay: String? = nil + let canTranslate: Bool + let hasTranslatedText: Bool + let isShowingTranslatedText: Bool + let onTranslate: () -> Void + let onToggleTranslatedText: () -> Void + let onClearTranslation: () -> Void + @State var relayDisplay: String? var body: some View { VStack { @@ -42,6 +48,25 @@ struct MessageContextMenuItems: View { Image(systemName: "arrowshape.turn.up.left") } + if canTranslate { + Button(action: onTranslate) { + Text("Translate") + Image(systemName: "translate") + } + } + + if hasTranslatedText { + Button(action: onToggleTranslatedText) { + Text(isShowingTranslatedText ? "Show Original" : "Show Translation") + Image(systemName: isShowingTranslatedText ? "text.bubble" : "globe") + } + + Button(role: .destructive, action: onClearTranslation) { + Text("Clear Translation") + Image(systemName: "trash") + } + } + Button { UIPasteboard.general.string = message.messagePayload } label: { @@ -56,6 +81,7 @@ struct MessageContextMenuItems: View { let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) // Compute a relay display string if relayNode is present + VStack { Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))") .foregroundColor(.gray) diff --git a/Meshtastic/Views/Messages/MessageText.swift b/Meshtastic/Views/Messages/MessageText.swift index 7427e14a..6343b91a 100644 --- a/Meshtastic/Views/Messages/MessageText.swift +++ b/Meshtastic/Views/Messages/MessageText.swift @@ -2,6 +2,9 @@ import MeshtasticProtobufs import OSLog import SwiftUI import DatadogSessionReplay +#if !targetEnvironment(macCatalyst) +import Translation +#endif struct MessageText: View { static let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */ @@ -27,6 +30,7 @@ struct MessageText: View { // State for handling channel URL sheet @State private var saveChannelLink: SaveChannelLinkData? @State private var isShowingDeleteConfirmation = false + @State private var isShowingTranslationPresentation = false @State private var tapbackText = "" @FocusState private var isTapbackInputFocused: Bool @@ -58,9 +62,54 @@ struct MessageText: View { } } + private var sourceMessageText: String { + message.messagePayload ?? "EMPTY MESSAGE" + } + + private var hasTranslatedText: Bool { message.hasTranslatedPayload } + + private var isShowingTranslatedText: Bool { + message.showTranslatedMessage && hasTranslatedText + } + + private var canTranslate: Bool { + guard !sourceMessageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + #if targetEnvironment(macCatalyst) + return false + #else + if #available(iOS 17.4, macOS 14.4, *) { + return true + } + return false + #endif + } + private var messageContent: some View { - let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE")) - return Text(markdownText) + #if !targetEnvironment(macCatalyst) + if #available(iOS 17.4, macOS 14.4, *), canTranslate { + return AnyView( + baseMessageContent + .translationPresentation( + isPresented: $isShowingTranslationPresentation, + text: sourceMessageText, + attachmentAnchor: .rect(.bounds), + arrowEdge: .top, + replacementAction: { replacement in + saveTranslatedText(replacement) + } + ) + ) + } + #endif + + return AnyView(baseMessageContent) + } + + private var baseMessageContent: some View { + let markdownText = LocalizedStringKey(message.displayedMarkdownPayload) + return Group { + Text(markdownText) + } .tint(Self.linkBlue) .padding(.vertical, 10) .padding(.horizontal, 8) @@ -89,7 +138,13 @@ struct MessageText: View { get: { isTapbackInputFocused }, set: { isTapbackInputFocused = $0 } ), - onReply: onReply + onReply: onReply, + canTranslate: canTranslate, + hasTranslatedText: hasTranslatedText, + isShowingTranslatedText: isShowingTranslatedText, + onTranslate: { isShowingTranslationPresentation = true }, + onToggleTranslatedText: { toggleTranslatedText() }, + onClearTranslation: { clearTranslation() } ) } } @@ -131,6 +186,14 @@ struct MessageText: View { .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) .offset(x: 20, y: -20) } + if isShowingTranslatedText { + Image(systemName: "translate") + .font(.system(size: 20)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .foregroundStyle(Color.blue) + .symbolRenderingMode(.hierarchical) + .offset(x: 38, y: 8) + } } private func handleURL(_ url: URL) -> OpenURLAction.Result { @@ -170,6 +233,41 @@ struct MessageText: View { Logger.data.error("Failed to delete message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") } } + + private func saveTranslatedText(_ replacement: String) { + message.messagePayloadTranslated = replacement + message.messagePayloadTranslatedMarkdown = generateMessageMarkdown(message: replacement) + message.showTranslatedMessage = true + + do { + try context.save() + } catch { + Logger.data.error("Failed to save translated message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + private func toggleTranslatedText() { + guard hasTranslatedText else { return } + message.showTranslatedMessage.toggle() + + do { + try context.save() + } catch { + Logger.data.error("Failed to toggle translated message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + private func clearTranslation() { + message.messagePayloadTranslated = nil + message.messagePayloadTranslatedMarkdown = nil + message.showTranslatedMessage = false + + do { + try context.save() + } catch { + Logger.data.error("Failed to clear translated message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } private func processTapback() { guard !tapbackText.isEmpty else { return } diff --git a/Meshtastic/Views/Messages/UserMessageRow.swift b/Meshtastic/Views/Messages/UserMessageRow.swift index d469462b..9f5f50eb 100644 --- a/Meshtastic/Views/Messages/UserMessageRow.swift +++ b/Meshtastic/Views/Messages/UserMessageRow.swift @@ -28,15 +28,15 @@ struct UserMessageRow: View { } init(message: MessageEntity, - allMessages: [MessageEntity], - previousMessage: MessageEntity?, - preferredPeripheralNum: Int, - user: UserEntity, - replyMessageId: Binding, - messageFieldFocused: FocusState.Binding, - messageToHighlight: Binding, - scrollView: ScrollViewProxy, - onInteractionComplete: @escaping () -> Void) { + allMessages: [MessageEntity], + previousMessage: MessageEntity?, + preferredPeripheralNum: Int, + user: UserEntity, + replyMessageId: Binding, + messageFieldFocused: FocusState.Binding, + messageToHighlight: Binding, + scrollView: ScrollViewProxy, + onInteractionComplete: @escaping () -> Void) { // Initialize ObservedObject with the concrete instance self._message = ObservedObject(initialValue: message) self.allMessages = allMessages @@ -88,7 +88,7 @@ struct UserMessageRow: View { Image(systemName: "arrowshape.turn.up.left.fill") .symbolRenderingMode(.hierarchical).imageScale(.large) .foregroundColor(.accentColor).padding(.leading) - Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2) + Text(messageReply?.displayedPayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2) } .padding(10) .overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.blue, lineWidth: 0.5)) diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index 495a2910..a9f0ad53 100644 --- a/Meshtastic/Views/Settings/AppSettings.swift +++ b/Meshtastic/Views/Settings/AppSettings.swift @@ -97,7 +97,7 @@ struct AppSettings: View { } #endif } - .onChange(of: usageDataAndCrashReporting) { oldUsageDataAndCrashReporting, newUsageDataAndCrashReporting in + .onChange(of: usageDataAndCrashReporting) { _, newUsageDataAndCrashReporting in if !newUsageDataAndCrashReporting { Datadog.set(trackingConsent: .notGranted) } diff --git a/README.md b/README.md index d2ab6c35..6e838ea3 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,82 @@ The last two major operating system versions are supported on iOS, iPadOS and ma ``` 2. Build, test, and commit the changes. +## Deep Links + +The app supports deep links using the `meshtastic:///` URL scheme, for use with shortcuts, intents, and web pages. + +### Messages + +| URL | Description | +|-----|-------------| +| `meshtastic:///messages` | Messages tab | +| `meshtastic:///messages?channelId={channelId}&messageId={messageId}` | Channel messages (`messageId` is optional) | +| `meshtastic:///messages?userNum={userNum}&messageId={messageId}` | Direct messages (`messageId` is optional) | + +### Connect + +| URL | Description | +|-----|-------------| +| `meshtastic:///connect` | Connect tab | + +### Nodes + +| URL | Description | +|-----|-------------| +| `meshtastic:///nodes` | Nodes tab | +| `meshtastic:///nodes?nodenum={nodenum}` | Selected node | + +### Mesh Map + +| URL | Description | +|-----|-------------| +| `meshtastic:///map` | Map tab | +| `meshtastic:///map?nodenum={nodenum}` | Node on map | +| `meshtastic:///map?waypointId={waypointId}` | Waypoint on map | + +### Settings + +Each settings item has an associated deep link. No parameters are supported for settings URLs. + +| URL | Description | +|-----|-------------| +| `meshtastic:///settings/about` | About Meshtastic | +| `meshtastic:///settings/appSettings` | App Settings | +| `meshtastic:///settings/routes` | Routes | +| `meshtastic:///settings/routeRecorder` | Route Recorder | +| **Radio Config** | | +| `meshtastic:///settings/lora` | LoRa Config | +| `meshtastic:///settings/channels` | Channels | +| `meshtastic:///settings/security` | Security Config | +| `meshtastic:///settings/shareQRCode` | Share QR Code | +| **Device Config** | | +| `meshtastic:///settings/user` | User Config | +| `meshtastic:///settings/bluetooth` | Bluetooth Config | +| `meshtastic:///settings/device` | Device Config | +| `meshtastic:///settings/display` | Display Config | +| `meshtastic:///settings/network` | Network Config | +| `meshtastic:///settings/position` | Position Config | +| `meshtastic:///settings/power` | Power Config | +| **Module Config** | | +| `meshtastic:///settings/ambientLighting` | Ambient Lighting | +| `meshtastic:///settings/cannedMessages` | Canned Messages | +| `meshtastic:///settings/detectionSensor` | Detection Sensor | +| `meshtastic:///settings/externalNotification` | External Notification | +| `meshtastic:///settings/mqtt` | MQTT | +| `meshtastic:///settings/paxCounter` | Pax Counter | +| `meshtastic:///settings/rangeTest` | Range Test | +| `meshtastic:///settings/ringtone` | Ringtone | +| `meshtastic:///settings/serial` | Serial | +| `meshtastic:///settings/storeAndForward` | Store & Forward | +| `meshtastic:///settings/telemetry` | Telemetry | +| **TAK** | | +| `meshtastic:///settings/tak` | TAK Config | +| **Logging** | | +| `meshtastic:///settings/debugLogs` | Debug Logs | +| **Developers** | | +| `meshtastic:///settings/appFiles` | App Files | +| `meshtastic:///settings/firmwareUpdates` | Firmware Updates | + ## Release Process For more information on how a new release of Meshtastic is managed, please refer to [RELEASING.md](./RELEASING.md) From 36c07ba68508d809682f2c83bca53b2a91c50376 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Mon, 6 Apr 2026 09:44:39 -0700 Subject: [PATCH 16/34] NFC Tag contact (#1600) * NFC Tag contact * Add Tools.swift to Xcode project file - fix missing file reference causing build failure Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/e3299e28-9ec0-4a23-98bc-5fc032750b4a Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Apply reviewer feedback: Catalyst guard, NDEF entitlement, nil guard, localized string, capacity check, preview fix Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/b86f9b74-5ee1-4144-87e5-3e4b6479ac44 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Log tag NDEF capacity on query for debugging Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/b86f9b74-5ee1-4144-87e5-3e4b6479ac44 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix formatting error * Linting fixes --------- Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --- Localizable.xcstrings | 19 ++ Meshtastic.xcodeproj/project.pbxproj | 4 + Meshtastic/Info.plist | 2 + Meshtastic/Meshtastic.entitlements | 4 + Meshtastic/Router/NavigationState.swift | 1 + .../Messages/MessageContextMenuItems.swift | 2 +- .../Nodes/Helpers/Map/WaypointForm.swift | 7 +- .../Config/Module/RangeTestConfig.swift | 1 - Meshtastic/Views/Settings/Settings.swift | 9 + Meshtastic/Views/Settings/Tools.swift | 186 ++++++++++++++++++ 10 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 Meshtastic/Views/Settings/Tools.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 1619558a..a21426e4 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -13743,6 +13743,10 @@ } } }, + "Create Node Contact NFC Tag" : { + "comment" : "A section header that instructs the user to create a contact NFC tag.", + "isCommentAutoGenerated" : true + }, "Create Waypoint" : { "localizations" : { "da" : { @@ -35452,6 +35456,10 @@ } } }, + "Node Name: %@" : { + "comment" : "A text label displaying the name of the connected node.", + "isCommentAutoGenerated" : true + }, "Node not heard recently. Shown without a pulsing ring on the map." : { "comment" : "A description of a node that is not heard by the user.", "isCommentAutoGenerated" : true @@ -45333,6 +45341,10 @@ } } }, + "RSSI %d dBm" : { + "comment" : "A label displaying the RSSI of a message.", + "isCommentAutoGenerated" : true + }, "RSSI %ddB" : { "localizations" : { "da" : { @@ -56280,6 +56292,9 @@ "Toggles the map legend" : { "comment" : "A hint for the user to toggle the map legend.", "isCommentAutoGenerated" : true + }, + "Tools" : { + }, "Topic: %@" : { "extractionState" : "stale", @@ -62114,6 +62129,10 @@ } } }, + "Write Contact to NFC Tag" : { + "comment" : "A button that writes a contact to an NFC tag.", + "isCommentAutoGenerated" : true + }, "x" : { "localizations" : { "da" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 7dd0ae93..4a83769b 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -101,6 +101,7 @@ 8E587743574CE17703E892C6 /* Certificates in Resources */ = {isa = PBXBuildFile; fileRef = 518D504DED9874EBF9D76578 /* Certificates */; }; 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; }; 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; }; + DCC919C6B47C15BB0795456C /* Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D5AD8037A0D583C614B0597 /* Tools.swift */; }; A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; }; AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; }; @@ -348,6 +349,7 @@ /* Begin PBXFileReference section */ 01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = ""; }; + 1D5AD8037A0D583C614B0597 /* Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tools.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 = ""; }; 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = ""; }; @@ -1034,6 +1036,7 @@ DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */, ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */, 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */, + 1D5AD8037A0D583C614B0597 /* Tools.swift */, ); path = Settings; sourceTree = ""; @@ -1942,6 +1945,7 @@ E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */, FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */, A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */, + DCC919C6B47C15BB0795456C /* Tools.swift in Sources */, 8398407DBA32EE7CFC16A385 /* TAKDataPackageGenerator.swift in Sources */, 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */, 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */, diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index 863fb0e9..c2cbcdd8 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -97,6 +97,8 @@ LSSupportsOpeningDocumentsInPlace + NFCReaderUsageDescription + We use NFC tags to share node contacts NSBluetoothAlwaysUsageDescription We use bluetooth to connect to nearby Meshtastic Devices NSBluetoothPeripheralUsageDescription diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index e8c10bea..4549e242 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -9,6 +9,10 @@ com.apple.developer.carplay-communication + com.apple.developer.nfc.readersession.formats + + NDEF + com.apple.developer.usernotifications.critical-alerts com.apple.developer.weatherkit diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index ca828478..173a2c4e 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -53,6 +53,7 @@ enum SettingsNavigationState: String { case appFiles case firmwareUpdates case tak + case tools } struct NavigationState: Hashable { diff --git a/Meshtastic/Views/Messages/MessageContextMenuItems.swift b/Meshtastic/Views/Messages/MessageContextMenuItems.swift index a97f3801..42f54da2 100644 --- a/Meshtastic/Views/Messages/MessageContextMenuItems.swift +++ b/Meshtastic/Views/Messages/MessageContextMenuItems.swift @@ -97,7 +97,7 @@ struct MessageContextMenuItems: View { if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) && message.fromUser?.userNode?.hopsAway ?? -1 == 0 { VStack { Text("SNR \(String(format: "%.2f", message.snr)) dB") - Text("RSSI \(String(format: "%.2f", message.rssi)) dBm") + Text("RSSI \(message.rssi) dBm") } } else if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) { VStack { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift index 6c11d1ef..53367b7a 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift @@ -32,9 +32,8 @@ struct WaypointForm: View { @State private var lockedTo: Int64 = 0 @State private var selectedDetent: PresentationDetent = .medium @State private var waypointFailedAlert: Bool = false - @State private var createdByNode : NodeInfoEntity? = nil - @State private var lastUpdatedByNode : NodeInfoEntity? = nil - + @State private var createdByNode: NodeInfoEntity? = nil + @State private var lastUpdatedByNode: NodeInfoEntity? = nil var body: some View { Group { @@ -530,5 +529,3 @@ struct WaypointForm: View { } } } - - diff --git a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift index 74386285..f3d19871 100644 --- a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift @@ -35,7 +35,6 @@ struct RangeTestConfig: View { return hexLen < 3 } - var body: some View { Form { ConfigHeader(title: "Range", config: \.rangeTestConfig, node: node, onAppear: setRangeTestValues) diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 1c953c73..d4fe2712 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -367,6 +367,13 @@ struct Settings: View { Image(systemName: "gearshape") } } + NavigationLink(value: SettingsNavigationState.tools) { + Label { + Text("Tools") + } icon: { + Image(systemName: "hammer") + } + } NavigationLink(value: SettingsNavigationState.routes) { Label { Text("Routes") @@ -534,6 +541,8 @@ struct Settings: View { AppData() case .firmwareUpdates: Firmware(node: node) + case .tools: + Tools() case .tak: TAKServerConfig() } diff --git a/Meshtastic/Views/Settings/Tools.swift b/Meshtastic/Views/Settings/Tools.swift new file mode 100644 index 00000000..897659d9 --- /dev/null +++ b/Meshtastic/Views/Settings/Tools.swift @@ -0,0 +1,186 @@ +// +// Tools.swift +// Meshtastic +// +// Created by Benjamin Faershtein on 12/31/25. +// + +import SwiftUI +#if !targetEnvironment(macCatalyst) +import CoreNFC +#endif +import MeshtasticProtobufs +import OSLog + +struct Tools: View { + @EnvironmentObject var accessoryManager: AccessoryManager + @Environment(\.managedObjectContext) var context + + #if !targetEnvironment(macCatalyst) + @StateObject private var nfcReader = NFCReader() + #endif + + var connectedNode: NodeInfoEntity? { + if let num = accessoryManager.activeDeviceNum { + return getNodeInfo(id: num, context: context) + } + return nil + } + + var qrString: String { + guard let connectedNode = connectedNode else { + return "" + } + + var contact = SharedContact() + contact.nodeNum = UInt32(connectedNode.num) + contact.user = connectedNode.toProto().user + contact.manuallyVerified = true + + do { + let contactString = try contact.serializedData().base64EncodedString() + return "https://meshtastic.org/v/#" + contactString.base64ToBase64url() + } catch { + Logger.services.error("Error serializing contact: \(error)") + return "" + } + } + + var body: some View { + VStack { + List { + Section(header: Text("Create Node Contact NFC Tag")) { + if let node = connectedNode { + Text("Node Name: \(node.user?.longName ?? "Unknown".localized)") + #if !targetEnvironment(macCatalyst) + Button { + nfcReader.scan(theActualData: qrString) + } label: { + Label("Write Contact to NFC Tag", systemImage: "tag") + } + .disabled(qrString.isEmpty) + #endif + } + } + } + } + .navigationTitle("Tools") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + let context = PersistenceController.preview.container.viewContext + return Tools() + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} + +#if !targetEnvironment(macCatalyst) +final class NFCReader: NSObject, ObservableObject, NFCNDEFReaderSessionDelegate { + + private let logger = Logger(subsystem: "org.meshtastic.app", category: "NFC") + private var payloadString = "" + private var session: NFCNDEFReaderSession? + + func scan(theActualData: String) { + payloadString = theActualData + + session = NFCNDEFReaderSession( + delegate: self, + queue: nil, + invalidateAfterFirstRead: false + ) + + session?.alertMessage = "Hold your iPhone near the NFC tag." + session?.begin() + } + + func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) { + logger.debug("NFC session became active") + } + + func readerSession(_ session: NFCNDEFReaderSession, + didInvalidateWithError error: Error) { + logger.error("NFC session invalidated: \(error.localizedDescription)") + } + + func readerSession(_ session: NFCNDEFReaderSession, + didDetectNDEFs messages: [NFCNDEFMessage]) { + } + + func readerSession(_ session: NFCNDEFReaderSession, + didDetect tags: [NFCNDEFTag]) { + + guard tags.count == 1, let tag = tags.first else { + session.alertMessage = "More than one tag detected. Please present only one." + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(500)) { + session.restartPolling() + } + return + } + + session.connect(to: tag) { error in + if let error { + self.logger.error("Failed to connect to tag: \(error.localizedDescription)") + session.alertMessage = "Failed to connect to tag." + session.invalidate() + return + } + + tag.queryNDEFStatus { status, capacity, error in + if let error { + self.logger.error("Failed to query NDEF status: \(error.localizedDescription)") + session.alertMessage = "Failed to read tag." + session.invalidate() + return + } + self.logger.debug("Tag NDEF status: \(String(describing: status)), capacity: \(capacity) bytes") + + switch status { + case .notSupported: + self.logger.error("Tag does not support NDEF") + session.alertMessage = "Tag does not support NDEF." + session.invalidate() + + case .readOnly: + self.logger.error("Tag is read-only") + session.alertMessage = "Tag is read-only." + session.invalidate() + + case .readWrite: + guard let payload = + NFCNDEFPayload.wellKnownTypeURIPayload( + string: self.payloadString + ) else { + self.logger.error("Invalid NDEF payload") + session.alertMessage = "Invalid payload." + session.invalidate() + return + } + + let message = NFCNDEFMessage(records: [payload]) + + guard message.length <= capacity else { + self.logger.error("Payload (\(message.length) bytes) exceeds tag capacity (\(capacity) bytes)") + session.alertMessage = "Tag too small to hold contact data." + session.invalidate() + return + } + + tag.writeNDEF(message) { error in + if let error { + self.logger.error("Failed to write NDEF: \(error.localizedDescription)") + session.alertMessage = "Failed to write tag." + } else { + self.logger.info("Successfully wrote NFC tag") + session.alertMessage = "NFC tag written successfully." + } + session.invalidate() + } + } + } + } + } +} +#endif From 8a7f67e8ac71296e4fc697a5eecf2c73186d4f06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:55:15 +0000 Subject: [PATCH 17/34] fix: Add @MainActor annotation to debounce Task in NodeList onChange handler Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/ec4c8629-8d84-4450-9df2-2818b06f9296 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --- Meshtastic/Views/Nodes/NodeList.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 798e4d6e..67dbdb81 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -130,7 +130,7 @@ struct NodeList: View { .onChange(of: router.nodeListSelectedNodeNum) { _, newNum in // Debounce rapid route changes β€” only process the last selection after a short delay nodeSelectionTask?.cancel() - nodeSelectionTask = Task { + nodeSelectionTask = Task { @MainActor in do { try await Task.sleep(nanoseconds: Self.nodeSelectionDebounceNs) } catch { From 352d338fd7741a4e2d1a9e05550cea2d12e91ddc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:04:22 -0700 Subject: [PATCH 18/34] fix: revert NFC entitlement from NDEF back to TAG (#1657) --- Meshtastic/Meshtastic.entitlements | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index 4549e242..bc694209 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -11,7 +11,7 @@ com.apple.developer.nfc.readersession.formats - NDEF + TAG com.apple.developer.usernotifications.critical-alerts From 64873e285e330db972e33f3491dde2279a45da30 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 7 Apr 2026 06:05:50 -0500 Subject: [PATCH 19/34] Update protobufs --- .../Accessory Manager/AccessoryManager.swift | 8 +- .../Sources/meshtastic/admin.pb.swift | 2132 ++++++++++------- .../Sources/meshtastic/config.pb.swift | 67 +- .../Sources/meshtastic/localonly.pb.swift | 20 +- .../Sources/meshtastic/mesh.pb.swift | 36 +- .../Sources/meshtastic/module_config.pb.swift | 89 +- .../Sources/meshtastic/portnums.pb.swift | 19 +- .../Sources/meshtastic/telemetry.pb.swift | 24 +- protobufs | 2 +- 9 files changed, 1461 insertions(+), 936 deletions(-) diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index 5e1a46bd..caa56ed4 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -650,10 +650,14 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { Logger.mesh.info("πŸ•ΈοΈ MESH PACKET received for Reticulum Tunnel App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") case .keyVerificationApp: Logger.mesh.warning("πŸ•ΈοΈ MESH PACKET received for Key Verification App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") - case .unknownApp: - Logger.mesh.warning("πŸ•ΈοΈ MESH PACKET received for unknown App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") case .cayenneApp: Logger.mesh.info("πŸ•ΈοΈ MESH PACKET received Cayenne App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + case .groupalarmApp: + Logger.mesh.info("πŸ•ΈοΈ MESH PACKET received Group Alarm App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + case .lorawanBridge: + Logger.mesh.info("πŸ•ΈοΈ MESH PACKET received for LoRaWAN Bridge UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + case .unknownApp: + Logger.mesh.warning("πŸ•ΈοΈ MESH PACKET received for unknown App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") } } diff --git a/MeshtasticProtobufs/Sources/meshtastic/admin.pb.swift b/MeshtasticProtobufs/Sources/meshtastic/admin.pb.swift index 5b1b5dee..0e14ecbd 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/admin.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/admin.pb.swift @@ -74,7 +74,7 @@ public enum OTAMode: SwiftProtobuf.Enum, Swift.CaseIterable { /// This message is handled by the Admin module and is responsible for all settings/channel read/write operations. /// This message is used to do settings operations to both remote AND local nodes. /// (Prior to 1.2 these operations were done via special ToRadio operations) -public struct AdminMessage: Sendable { +public struct AdminMessage: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -83,201 +83,207 @@ public struct AdminMessage: Sendable { /// The node generates this key and sends it with any get_x_response packets. /// The client MUST include the same key with any set_x commands. Key expires after 300 seconds. /// Prevents replay attacks for admin messages. - public var sessionPasskey: Data = Data() + public var sessionPasskey: Data { + get {return _storage._sessionPasskey} + set {_uniqueStorage()._sessionPasskey = newValue} + } /// /// TODO: REPLACE - public var payloadVariant: AdminMessage.OneOf_PayloadVariant? = nil + public var payloadVariant: OneOf_PayloadVariant? { + get {return _storage._payloadVariant} + set {_uniqueStorage()._payloadVariant = newValue} + } /// /// Send the specified channel in the response to this message /// NOTE: This field is sent with the channel index + 1 (to ensure we never try to send 'zero' - which protobufs treats as not present) public var getChannelRequest: UInt32 { get { - if case .getChannelRequest(let v)? = payloadVariant {return v} + if case .getChannelRequest(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .getChannelRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .getChannelRequest(newValue)} } /// /// TODO: REPLACE public var getChannelResponse: Channel { get { - if case .getChannelResponse(let v)? = payloadVariant {return v} + if case .getChannelResponse(let v)? = _storage._payloadVariant {return v} return Channel() } - set {payloadVariant = .getChannelResponse(newValue)} + set {_uniqueStorage()._payloadVariant = .getChannelResponse(newValue)} } /// /// Send the current owner data in the response to this message. public var getOwnerRequest: Bool { get { - if case .getOwnerRequest(let v)? = payloadVariant {return v} + if case .getOwnerRequest(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .getOwnerRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .getOwnerRequest(newValue)} } /// /// TODO: REPLACE public var getOwnerResponse: User { get { - if case .getOwnerResponse(let v)? = payloadVariant {return v} + if case .getOwnerResponse(let v)? = _storage._payloadVariant {return v} return User() } - set {payloadVariant = .getOwnerResponse(newValue)} + set {_uniqueStorage()._payloadVariant = .getOwnerResponse(newValue)} } /// /// Ask for the following config data to be sent public var getConfigRequest: AdminMessage.ConfigType { get { - if case .getConfigRequest(let v)? = payloadVariant {return v} + if case .getConfigRequest(let v)? = _storage._payloadVariant {return v} return .deviceConfig } - set {payloadVariant = .getConfigRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .getConfigRequest(newValue)} } /// /// Send the current Config in the response to this message. public var getConfigResponse: Config { get { - if case .getConfigResponse(let v)? = payloadVariant {return v} + if case .getConfigResponse(let v)? = _storage._payloadVariant {return v} return Config() } - set {payloadVariant = .getConfigResponse(newValue)} + set {_uniqueStorage()._payloadVariant = .getConfigResponse(newValue)} } /// /// Ask for the following config data to be sent public var getModuleConfigRequest: AdminMessage.ModuleConfigType { get { - if case .getModuleConfigRequest(let v)? = payloadVariant {return v} + if case .getModuleConfigRequest(let v)? = _storage._payloadVariant {return v} return .mqttConfig } - set {payloadVariant = .getModuleConfigRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .getModuleConfigRequest(newValue)} } /// /// Send the current Config in the response to this message. public var getModuleConfigResponse: ModuleConfig { get { - if case .getModuleConfigResponse(let v)? = payloadVariant {return v} + if case .getModuleConfigResponse(let v)? = _storage._payloadVariant {return v} return ModuleConfig() } - set {payloadVariant = .getModuleConfigResponse(newValue)} + set {_uniqueStorage()._payloadVariant = .getModuleConfigResponse(newValue)} } /// /// Get the Canned Message Module messages in the response to this message. public var getCannedMessageModuleMessagesRequest: Bool { get { - if case .getCannedMessageModuleMessagesRequest(let v)? = payloadVariant {return v} + if case .getCannedMessageModuleMessagesRequest(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .getCannedMessageModuleMessagesRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .getCannedMessageModuleMessagesRequest(newValue)} } /// /// Get the Canned Message Module messages in the response to this message. public var getCannedMessageModuleMessagesResponse: String { get { - if case .getCannedMessageModuleMessagesResponse(let v)? = payloadVariant {return v} + if case .getCannedMessageModuleMessagesResponse(let v)? = _storage._payloadVariant {return v} return String() } - set {payloadVariant = .getCannedMessageModuleMessagesResponse(newValue)} + set {_uniqueStorage()._payloadVariant = .getCannedMessageModuleMessagesResponse(newValue)} } /// /// Request the node to send device metadata (firmware, protobuf version, etc) public var getDeviceMetadataRequest: Bool { get { - if case .getDeviceMetadataRequest(let v)? = payloadVariant {return v} + if case .getDeviceMetadataRequest(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .getDeviceMetadataRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .getDeviceMetadataRequest(newValue)} } /// /// Device metadata response public var getDeviceMetadataResponse: DeviceMetadata { get { - if case .getDeviceMetadataResponse(let v)? = payloadVariant {return v} + if case .getDeviceMetadataResponse(let v)? = _storage._payloadVariant {return v} return DeviceMetadata() } - set {payloadVariant = .getDeviceMetadataResponse(newValue)} + set {_uniqueStorage()._payloadVariant = .getDeviceMetadataResponse(newValue)} } /// /// Get the Ringtone in the response to this message. public var getRingtoneRequest: Bool { get { - if case .getRingtoneRequest(let v)? = payloadVariant {return v} + if case .getRingtoneRequest(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .getRingtoneRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .getRingtoneRequest(newValue)} } /// /// Get the Ringtone in the response to this message. public var getRingtoneResponse: String { get { - if case .getRingtoneResponse(let v)? = payloadVariant {return v} + if case .getRingtoneResponse(let v)? = _storage._payloadVariant {return v} return String() } - set {payloadVariant = .getRingtoneResponse(newValue)} + set {_uniqueStorage()._payloadVariant = .getRingtoneResponse(newValue)} } /// /// Request the node to send it's connection status public var getDeviceConnectionStatusRequest: Bool { get { - if case .getDeviceConnectionStatusRequest(let v)? = payloadVariant {return v} + if case .getDeviceConnectionStatusRequest(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .getDeviceConnectionStatusRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .getDeviceConnectionStatusRequest(newValue)} } /// /// Device connection status response public var getDeviceConnectionStatusResponse: DeviceConnectionStatus { get { - if case .getDeviceConnectionStatusResponse(let v)? = payloadVariant {return v} + if case .getDeviceConnectionStatusResponse(let v)? = _storage._payloadVariant {return v} return DeviceConnectionStatus() } - set {payloadVariant = .getDeviceConnectionStatusResponse(newValue)} + set {_uniqueStorage()._payloadVariant = .getDeviceConnectionStatusResponse(newValue)} } /// /// Setup a node for licensed amateur (ham) radio operation public var setHamMode: HamParameters { get { - if case .setHamMode(let v)? = payloadVariant {return v} + if case .setHamMode(let v)? = _storage._payloadVariant {return v} return HamParameters() } - set {payloadVariant = .setHamMode(newValue)} + set {_uniqueStorage()._payloadVariant = .setHamMode(newValue)} } /// /// Get the mesh's nodes with their available gpio pins for RemoteHardware module use public var getNodeRemoteHardwarePinsRequest: Bool { get { - if case .getNodeRemoteHardwarePinsRequest(let v)? = payloadVariant {return v} + if case .getNodeRemoteHardwarePinsRequest(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .getNodeRemoteHardwarePinsRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .getNodeRemoteHardwarePinsRequest(newValue)} } /// /// Respond with the mesh's nodes with their available gpio pins for RemoteHardware module use public var getNodeRemoteHardwarePinsResponse: NodeRemoteHardwarePinsResponse { get { - if case .getNodeRemoteHardwarePinsResponse(let v)? = payloadVariant {return v} + if case .getNodeRemoteHardwarePinsResponse(let v)? = _storage._payloadVariant {return v} return NodeRemoteHardwarePinsResponse() } - set {payloadVariant = .getNodeRemoteHardwarePinsResponse(newValue)} + set {_uniqueStorage()._payloadVariant = .getNodeRemoteHardwarePinsResponse(newValue)} } /// @@ -285,60 +291,60 @@ public struct AdminMessage: Sendable { /// Only implemented on NRF52 currently public var enterDfuModeRequest: Bool { get { - if case .enterDfuModeRequest(let v)? = payloadVariant {return v} + if case .enterDfuModeRequest(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .enterDfuModeRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .enterDfuModeRequest(newValue)} } /// /// Delete the file by the specified path from the device public var deleteFileRequest: String { get { - if case .deleteFileRequest(let v)? = payloadVariant {return v} + if case .deleteFileRequest(let v)? = _storage._payloadVariant {return v} return String() } - set {payloadVariant = .deleteFileRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .deleteFileRequest(newValue)} } /// /// Set zero and offset for scale chips public var setScale: UInt32 { get { - if case .setScale(let v)? = payloadVariant {return v} + if case .setScale(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .setScale(newValue)} + set {_uniqueStorage()._payloadVariant = .setScale(newValue)} } /// /// Backup the node's preferences public var backupPreferences: AdminMessage.BackupLocation { get { - if case .backupPreferences(let v)? = payloadVariant {return v} + if case .backupPreferences(let v)? = _storage._payloadVariant {return v} return .flash } - set {payloadVariant = .backupPreferences(newValue)} + set {_uniqueStorage()._payloadVariant = .backupPreferences(newValue)} } /// /// Restore the node's preferences public var restorePreferences: AdminMessage.BackupLocation { get { - if case .restorePreferences(let v)? = payloadVariant {return v} + if case .restorePreferences(let v)? = _storage._payloadVariant {return v} return .flash } - set {payloadVariant = .restorePreferences(newValue)} + set {_uniqueStorage()._payloadVariant = .restorePreferences(newValue)} } /// /// Remove backups of the node's preferences public var removeBackupPreferences: AdminMessage.BackupLocation { get { - if case .removeBackupPreferences(let v)? = payloadVariant {return v} + if case .removeBackupPreferences(let v)? = _storage._payloadVariant {return v} return .flash } - set {payloadVariant = .removeBackupPreferences(newValue)} + set {_uniqueStorage()._payloadVariant = .removeBackupPreferences(newValue)} } /// @@ -346,20 +352,20 @@ public struct AdminMessage: Sendable { /// This is used to trigger physical input events like button presses, touch events, etc. public var sendInputEvent: AdminMessage.InputEvent { get { - if case .sendInputEvent(let v)? = payloadVariant {return v} + if case .sendInputEvent(let v)? = _storage._payloadVariant {return v} return AdminMessage.InputEvent() } - set {payloadVariant = .sendInputEvent(newValue)} + set {_uniqueStorage()._payloadVariant = .sendInputEvent(newValue)} } /// /// Set the owner for this node public var setOwner: User { get { - if case .setOwner(let v)? = payloadVariant {return v} + if case .setOwner(let v)? = _storage._payloadVariant {return v} return User() } - set {payloadVariant = .setOwner(newValue)} + set {_uniqueStorage()._payloadVariant = .setOwner(newValue)} } /// @@ -370,100 +376,100 @@ public struct AdminMessage: Sendable { /// If the client sets a particular channel to be primary, the previous channel will be set to SECONDARY automatically. public var setChannel: Channel { get { - if case .setChannel(let v)? = payloadVariant {return v} + if case .setChannel(let v)? = _storage._payloadVariant {return v} return Channel() } - set {payloadVariant = .setChannel(newValue)} + set {_uniqueStorage()._payloadVariant = .setChannel(newValue)} } /// /// Set the current Config public var setConfig: Config { get { - if case .setConfig(let v)? = payloadVariant {return v} + if case .setConfig(let v)? = _storage._payloadVariant {return v} return Config() } - set {payloadVariant = .setConfig(newValue)} + set {_uniqueStorage()._payloadVariant = .setConfig(newValue)} } /// /// Set the current Config public var setModuleConfig: ModuleConfig { get { - if case .setModuleConfig(let v)? = payloadVariant {return v} + if case .setModuleConfig(let v)? = _storage._payloadVariant {return v} return ModuleConfig() } - set {payloadVariant = .setModuleConfig(newValue)} + set {_uniqueStorage()._payloadVariant = .setModuleConfig(newValue)} } /// /// Set the Canned Message Module messages text. public var setCannedMessageModuleMessages: String { get { - if case .setCannedMessageModuleMessages(let v)? = payloadVariant {return v} + if case .setCannedMessageModuleMessages(let v)? = _storage._payloadVariant {return v} return String() } - set {payloadVariant = .setCannedMessageModuleMessages(newValue)} + set {_uniqueStorage()._payloadVariant = .setCannedMessageModuleMessages(newValue)} } /// /// Set the ringtone for ExternalNotification. public var setRingtoneMessage: String { get { - if case .setRingtoneMessage(let v)? = payloadVariant {return v} + if case .setRingtoneMessage(let v)? = _storage._payloadVariant {return v} return String() } - set {payloadVariant = .setRingtoneMessage(newValue)} + set {_uniqueStorage()._payloadVariant = .setRingtoneMessage(newValue)} } /// /// Remove the node by the specified node-num from the NodeDB on the device public var removeByNodenum: UInt32 { get { - if case .removeByNodenum(let v)? = payloadVariant {return v} + if case .removeByNodenum(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .removeByNodenum(newValue)} + set {_uniqueStorage()._payloadVariant = .removeByNodenum(newValue)} } /// /// Set specified node-num to be favorited on the NodeDB on the device public var setFavoriteNode: UInt32 { get { - if case .setFavoriteNode(let v)? = payloadVariant {return v} + if case .setFavoriteNode(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .setFavoriteNode(newValue)} + set {_uniqueStorage()._payloadVariant = .setFavoriteNode(newValue)} } /// /// Set specified node-num to be un-favorited on the NodeDB on the device public var removeFavoriteNode: UInt32 { get { - if case .removeFavoriteNode(let v)? = payloadVariant {return v} + if case .removeFavoriteNode(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .removeFavoriteNode(newValue)} + set {_uniqueStorage()._payloadVariant = .removeFavoriteNode(newValue)} } /// /// Set fixed position data on the node and then set the position.fixed_position = true public var setFixedPosition: Position { get { - if case .setFixedPosition(let v)? = payloadVariant {return v} + if case .setFixedPosition(let v)? = _storage._payloadVariant {return v} return Position() } - set {payloadVariant = .setFixedPosition(newValue)} + set {_uniqueStorage()._payloadVariant = .setFixedPosition(newValue)} } /// /// Clear fixed position coordinates and then set position.fixed_position = false public var removeFixedPosition: Bool { get { - if case .removeFixedPosition(let v)? = payloadVariant {return v} + if case .removeFixedPosition(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .removeFixedPosition(newValue)} + set {_uniqueStorage()._payloadVariant = .removeFixedPosition(newValue)} } /// @@ -471,70 +477,70 @@ public struct AdminMessage: Sendable { /// Convenience method to set the time on the node (as Net quality) without any other position data public var setTimeOnly: UInt32 { get { - if case .setTimeOnly(let v)? = payloadVariant {return v} + if case .setTimeOnly(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .setTimeOnly(newValue)} + set {_uniqueStorage()._payloadVariant = .setTimeOnly(newValue)} } /// /// Tell the node to send the stored ui data. public var getUiConfigRequest: Bool { get { - if case .getUiConfigRequest(let v)? = payloadVariant {return v} + if case .getUiConfigRequest(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .getUiConfigRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .getUiConfigRequest(newValue)} } /// /// Reply stored device ui data. public var getUiConfigResponse: DeviceUIConfig { get { - if case .getUiConfigResponse(let v)? = payloadVariant {return v} + if case .getUiConfigResponse(let v)? = _storage._payloadVariant {return v} return DeviceUIConfig() } - set {payloadVariant = .getUiConfigResponse(newValue)} + set {_uniqueStorage()._payloadVariant = .getUiConfigResponse(newValue)} } /// /// Tell the node to store UI data persistently. public var storeUiConfig: DeviceUIConfig { get { - if case .storeUiConfig(let v)? = payloadVariant {return v} + if case .storeUiConfig(let v)? = _storage._payloadVariant {return v} return DeviceUIConfig() } - set {payloadVariant = .storeUiConfig(newValue)} + set {_uniqueStorage()._payloadVariant = .storeUiConfig(newValue)} } /// /// Set specified node-num to be ignored on the NodeDB on the device public var setIgnoredNode: UInt32 { get { - if case .setIgnoredNode(let v)? = payloadVariant {return v} + if case .setIgnoredNode(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .setIgnoredNode(newValue)} + set {_uniqueStorage()._payloadVariant = .setIgnoredNode(newValue)} } /// /// Set specified node-num to be un-ignored on the NodeDB on the device public var removeIgnoredNode: UInt32 { get { - if case .removeIgnoredNode(let v)? = payloadVariant {return v} + if case .removeIgnoredNode(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .removeIgnoredNode(newValue)} + set {_uniqueStorage()._payloadVariant = .removeIgnoredNode(newValue)} } /// /// Set specified node-num to be muted public var toggleMutedNode: UInt32 { get { - if case .toggleMutedNode(let v)? = payloadVariant {return v} + if case .toggleMutedNode(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .toggleMutedNode(newValue)} + set {_uniqueStorage()._payloadVariant = .toggleMutedNode(newValue)} } /// @@ -542,50 +548,50 @@ public struct AdminMessage: Sendable { /// This will delay the standard *implicit* save to the file system and subsequent reboot behavior until committed (commit_edit_settings) public var beginEditSettings: Bool { get { - if case .beginEditSettings(let v)? = payloadVariant {return v} + if case .beginEditSettings(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .beginEditSettings(newValue)} + set {_uniqueStorage()._payloadVariant = .beginEditSettings(newValue)} } /// /// Commits an open transaction for any edits made to config, module config, owner, and channel settings public var commitEditSettings: Bool { get { - if case .commitEditSettings(let v)? = payloadVariant {return v} + if case .commitEditSettings(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .commitEditSettings(newValue)} + set {_uniqueStorage()._payloadVariant = .commitEditSettings(newValue)} } /// /// Add a contact (User) to the nodedb public var addContact: SharedContact { get { - if case .addContact(let v)? = payloadVariant {return v} + if case .addContact(let v)? = _storage._payloadVariant {return v} return SharedContact() } - set {payloadVariant = .addContact(newValue)} + set {_uniqueStorage()._payloadVariant = .addContact(newValue)} } /// /// Initiate or respond to a key verification request public var keyVerification: KeyVerificationAdmin { get { - if case .keyVerification(let v)? = payloadVariant {return v} + if case .keyVerification(let v)? = _storage._payloadVariant {return v} return KeyVerificationAdmin() } - set {payloadVariant = .keyVerification(newValue)} + set {_uniqueStorage()._payloadVariant = .keyVerification(newValue)} } /// /// Tell the node to factory reset config everything; all device state and configuration will be returned to factory defaults and BLE bonds will be cleared. public var factoryResetDevice: Int32 { get { - if case .factoryResetDevice(let v)? = payloadVariant {return v} + if case .factoryResetDevice(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .factoryResetDevice(newValue)} + set {_uniqueStorage()._payloadVariant = .factoryResetDevice(newValue)} } /// @@ -596,10 +602,10 @@ public struct AdminMessage: Sendable { /// NOTE: This field was marked as deprecated in the .proto file. public var rebootOtaSeconds: Int32 { get { - if case .rebootOtaSeconds(let v)? = payloadVariant {return v} + if case .rebootOtaSeconds(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .rebootOtaSeconds(newValue)} + set {_uniqueStorage()._payloadVariant = .rebootOtaSeconds(newValue)} } /// @@ -607,40 +613,40 @@ public struct AdminMessage: Sendable { /// If received the simulator will exit successfully. public var exitSimulator: Bool { get { - if case .exitSimulator(let v)? = payloadVariant {return v} + if case .exitSimulator(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .exitSimulator(newValue)} + set {_uniqueStorage()._payloadVariant = .exitSimulator(newValue)} } /// /// Tell the node to reboot in this many seconds (or <0 to cancel reboot) public var rebootSeconds: Int32 { get { - if case .rebootSeconds(let v)? = payloadVariant {return v} + if case .rebootSeconds(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .rebootSeconds(newValue)} + set {_uniqueStorage()._payloadVariant = .rebootSeconds(newValue)} } /// /// Tell the node to shutdown in this many seconds (or <0 to cancel shutdown) public var shutdownSeconds: Int32 { get { - if case .shutdownSeconds(let v)? = payloadVariant {return v} + if case .shutdownSeconds(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .shutdownSeconds(newValue)} + set {_uniqueStorage()._payloadVariant = .shutdownSeconds(newValue)} } /// /// Tell the node to factory reset config; all device state and configuration will be returned to factory defaults; BLE bonds will be preserved. public var factoryResetConfig: Int32 { get { - if case .factoryResetConfig(let v)? = payloadVariant {return v} + if case .factoryResetConfig(let v)? = _storage._payloadVariant {return v} return 0 } - set {payloadVariant = .factoryResetConfig(newValue)} + set {_uniqueStorage()._payloadVariant = .factoryResetConfig(newValue)} } /// @@ -648,30 +654,30 @@ public struct AdminMessage: Sendable { /// When true, favorites are preserved through reset. public var nodedbReset: Bool { get { - if case .nodedbReset(let v)? = payloadVariant {return v} + if case .nodedbReset(let v)? = _storage._payloadVariant {return v} return false } - set {payloadVariant = .nodedbReset(newValue)} + set {_uniqueStorage()._payloadVariant = .nodedbReset(newValue)} } /// /// Tell the node to reset into the OTA Loader public var otaRequest: AdminMessage.OTAEvent { get { - if case .otaRequest(let v)? = payloadVariant {return v} + if case .otaRequest(let v)? = _storage._payloadVariant {return v} return AdminMessage.OTAEvent() } - set {payloadVariant = .otaRequest(newValue)} + set {_uniqueStorage()._payloadVariant = .otaRequest(newValue)} } /// /// Parameters and sensor configuration public var sensorConfig: SensorConfig { get { - if case .sensorConfig(let v)? = payloadVariant {return v} + if case .sensorConfig(let v)? = _storage._payloadVariant {return v} return SensorConfig() } - set {payloadVariant = .sensorConfig(newValue)} + set {_uniqueStorage()._payloadVariant = .sensorConfig(newValue)} } public var unknownFields = SwiftProtobuf.UnknownStorage() @@ -1030,6 +1036,10 @@ public struct AdminMessage: Sendable { /// /// Traffic management module config case trafficmanagementConfig // = 14 + + /// + /// TAK module config + case takConfig // = 15 case UNRECOGNIZED(Int) public init() { @@ -1053,6 +1063,7 @@ public struct AdminMessage: Sendable { case 12: self = .paxcounterConfig case 13: self = .statusmessageConfig case 14: self = .trafficmanagementConfig + case 15: self = .takConfig default: self = .UNRECOGNIZED(rawValue) } } @@ -1074,6 +1085,7 @@ public struct AdminMessage: Sendable { case .paxcounterConfig: return 12 case .statusmessageConfig: return 13 case .trafficmanagementConfig: return 14 + case .takConfig: return 15 case .UNRECOGNIZED(let i): return i } } @@ -1095,6 +1107,7 @@ public struct AdminMessage: Sendable { .paxcounterConfig, .statusmessageConfig, .trafficmanagementConfig, + .takConfig, ] } @@ -1190,6 +1203,8 @@ public struct AdminMessage: Sendable { } public init() {} + + fileprivate var _storage = _StorageClass.defaultInstance } /// @@ -1392,12 +1407,36 @@ public struct SensorConfig: Sendable { /// Clears the value of `sen5XConfig`. Subsequent reads from it will return its default value. public mutating func clearSen5XConfig() {self._sen5XConfig = nil} + /// + /// SCD30 CO2 Sensor configuration + public var scd30Config: SCD30_config { + get {return _scd30Config ?? SCD30_config()} + set {_scd30Config = newValue} + } + /// Returns true if `scd30Config` has been explicitly set. + public var hasScd30Config: Bool {return self._scd30Config != nil} + /// Clears the value of `scd30Config`. Subsequent reads from it will return its default value. + public mutating func clearScd30Config() {self._scd30Config = nil} + + /// + /// SHTXX temperature and relative humidity sensor configuration + public var shtxxConfig: SHTXX_config { + get {return _shtxxConfig ?? SHTXX_config()} + set {_shtxxConfig = newValue} + } + /// Returns true if `shtxxConfig` has been explicitly set. + public var hasShtxxConfig: Bool {return self._shtxxConfig != nil} + /// Clears the value of `shtxxConfig`. Subsequent reads from it will return its default value. + public mutating func clearShtxxConfig() {self._shtxxConfig = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} fileprivate var _scd4XConfig: SCD4X_config? = nil fileprivate var _sen5XConfig: SEN5X_config? = nil + fileprivate var _scd30Config: SCD30_config? = nil + fileprivate var _shtxxConfig: SHTXX_config? = nil } public struct SCD4X_config: Sendable { @@ -1530,6 +1569,112 @@ public struct SEN5X_config: Sendable { fileprivate var _setOneShotMode: Bool? = nil } +public struct SCD30_config: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// + /// Set Automatic self-calibration enabled + public var setAsc: Bool { + get {return _setAsc ?? false} + set {_setAsc = newValue} + } + /// Returns true if `setAsc` has been explicitly set. + public var hasSetAsc: Bool {return self._setAsc != nil} + /// Clears the value of `setAsc`. Subsequent reads from it will return its default value. + public mutating func clearSetAsc() {self._setAsc = nil} + + /// + /// Recalibration target CO2 concentration in ppm (FRC or ASC) + public var setTargetCo2Conc: UInt32 { + get {return _setTargetCo2Conc ?? 0} + set {_setTargetCo2Conc = newValue} + } + /// Returns true if `setTargetCo2Conc` has been explicitly set. + public var hasSetTargetCo2Conc: Bool {return self._setTargetCo2Conc != nil} + /// Clears the value of `setTargetCo2Conc`. Subsequent reads from it will return its default value. + public mutating func clearSetTargetCo2Conc() {self._setTargetCo2Conc = nil} + + /// + /// Reference temperature in degC + public var setTemperature: Float { + get {return _setTemperature ?? 0} + set {_setTemperature = newValue} + } + /// Returns true if `setTemperature` has been explicitly set. + public var hasSetTemperature: Bool {return self._setTemperature != nil} + /// Clears the value of `setTemperature`. Subsequent reads from it will return its default value. + public mutating func clearSetTemperature() {self._setTemperature = nil} + + /// + /// Altitude of sensor in meters above sea level. 0 - 3000m (overrides ambient pressure) + public var setAltitude: UInt32 { + get {return _setAltitude ?? 0} + set {_setAltitude = newValue} + } + /// Returns true if `setAltitude` has been explicitly set. + public var hasSetAltitude: Bool {return self._setAltitude != nil} + /// Clears the value of `setAltitude`. Subsequent reads from it will return its default value. + public mutating func clearSetAltitude() {self._setAltitude = nil} + + /// + /// Power mode for sensor (true for low power, false for normal) + public var setMeasurementInterval: UInt32 { + get {return _setMeasurementInterval ?? 0} + set {_setMeasurementInterval = newValue} + } + /// Returns true if `setMeasurementInterval` has been explicitly set. + public var hasSetMeasurementInterval: Bool {return self._setMeasurementInterval != nil} + /// Clears the value of `setMeasurementInterval`. Subsequent reads from it will return its default value. + public mutating func clearSetMeasurementInterval() {self._setMeasurementInterval = nil} + + /// + /// Perform a factory reset of the sensor + public var softReset: Bool { + get {return _softReset ?? false} + set {_softReset = newValue} + } + /// Returns true if `softReset` has been explicitly set. + public var hasSoftReset: Bool {return self._softReset != nil} + /// Clears the value of `softReset`. Subsequent reads from it will return its default value. + public mutating func clearSoftReset() {self._softReset = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _setAsc: Bool? = nil + fileprivate var _setTargetCo2Conc: UInt32? = nil + fileprivate var _setTemperature: Float? = nil + fileprivate var _setAltitude: UInt32? = nil + fileprivate var _setMeasurementInterval: UInt32? = nil + fileprivate var _softReset: Bool? = nil +} + +public struct SHTXX_config: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// + /// Accuracy mode (0 = low, 1 = medium, 2 = high) + public var setAccuracy: UInt32 { + get {return _setAccuracy ?? 0} + set {_setAccuracy = newValue} + } + /// Returns true if `setAccuracy` has been explicitly set. + public var hasSetAccuracy: Bool {return self._setAccuracy != nil} + /// Clears the value of `setAccuracy`. Subsequent reads from it will return its default value. + public mutating func clearSetAccuracy() {self._setAccuracy = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _setAccuracy: UInt32? = nil +} + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "meshtastic" @@ -1542,822 +1687,860 @@ extension AdminMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat public static let protoMessageName: String = _protobuf_package + ".AdminMessage" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}get_channel_request\0\u{3}get_channel_response\0\u{3}get_owner_request\0\u{3}get_owner_response\0\u{3}get_config_request\0\u{3}get_config_response\0\u{3}get_module_config_request\0\u{3}get_module_config_response\0\u{4}\u{2}get_canned_message_module_messages_request\0\u{3}get_canned_message_module_messages_response\0\u{3}get_device_metadata_request\0\u{3}get_device_metadata_response\0\u{3}get_ringtone_request\0\u{3}get_ringtone_response\0\u{3}get_device_connection_status_request\0\u{3}get_device_connection_status_response\0\u{3}set_ham_mode\0\u{3}get_node_remote_hardware_pins_request\0\u{3}get_node_remote_hardware_pins_response\0\u{3}enter_dfu_mode_request\0\u{3}delete_file_request\0\u{3}set_scale\0\u{3}backup_preferences\0\u{3}restore_preferences\0\u{3}remove_backup_preferences\0\u{3}send_input_event\0\u{4}\u{5}set_owner\0\u{3}set_channel\0\u{3}set_config\0\u{3}set_module_config\0\u{3}set_canned_message_module_messages\0\u{3}set_ringtone_message\0\u{3}remove_by_nodenum\0\u{3}set_favorite_node\0\u{3}remove_favorite_node\0\u{3}set_fixed_position\0\u{3}remove_fixed_position\0\u{3}set_time_only\0\u{3}get_ui_config_request\0\u{3}get_ui_config_response\0\u{3}store_ui_config\0\u{3}set_ignored_node\0\u{3}remove_ignored_node\0\u{3}toggle_muted_node\0\u{4}\u{f}begin_edit_settings\0\u{3}commit_edit_settings\0\u{3}add_contact\0\u{3}key_verification\0\u{4}\u{1b}factory_reset_device\0\u{3}reboot_ota_seconds\0\u{3}exit_simulator\0\u{3}reboot_seconds\0\u{3}shutdown_seconds\0\u{3}factory_reset_config\0\u{3}nodedb_reset\0\u{3}session_passkey\0\u{3}ota_request\0\u{3}sensor_config\0") + fileprivate class _StorageClass { + var _sessionPasskey: Data = Data() + var _payloadVariant: AdminMessage.OneOf_PayloadVariant? + + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + + private init() {} + + init(copying source: _StorageClass) { + _sessionPasskey = source._sessionPasskey + _payloadVariant = source._payloadVariant + } + } + + fileprivate mutating func _uniqueStorage() -> _StorageClass { + if !isKnownUniquelyReferenced(&_storage) { + _storage = _StorageClass(copying: _storage) + } + return _storage + } + public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { - var v: UInt32? - try decoder.decodeSingularUInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getChannelRequest(v) + _ = _uniqueStorage() + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { + var v: UInt32? + try decoder.decodeSingularUInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getChannelRequest(v) + } + }() + case 2: try { + var v: Channel? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .getChannelResponse(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getChannelResponse(v) + } + }() + case 3: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getOwnerRequest(v) + } + }() + case 4: try { + var v: User? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .getOwnerResponse(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getOwnerResponse(v) + } + }() + case 5: try { + var v: AdminMessage.ConfigType? + try decoder.decodeSingularEnumField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getConfigRequest(v) + } + }() + case 6: try { + var v: Config? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .getConfigResponse(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getConfigResponse(v) + } + }() + case 7: try { + var v: AdminMessage.ModuleConfigType? + try decoder.decodeSingularEnumField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getModuleConfigRequest(v) + } + }() + case 8: try { + var v: ModuleConfig? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .getModuleConfigResponse(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getModuleConfigResponse(v) + } + }() + case 10: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getCannedMessageModuleMessagesRequest(v) + } + }() + case 11: try { + var v: String? + try decoder.decodeSingularStringField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getCannedMessageModuleMessagesResponse(v) + } + }() + case 12: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getDeviceMetadataRequest(v) + } + }() + case 13: try { + var v: DeviceMetadata? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .getDeviceMetadataResponse(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getDeviceMetadataResponse(v) + } + }() + case 14: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getRingtoneRequest(v) + } + }() + case 15: try { + var v: String? + try decoder.decodeSingularStringField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getRingtoneResponse(v) + } + }() + case 16: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getDeviceConnectionStatusRequest(v) + } + }() + case 17: try { + var v: DeviceConnectionStatus? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .getDeviceConnectionStatusResponse(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getDeviceConnectionStatusResponse(v) + } + }() + case 18: try { + var v: HamParameters? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .setHamMode(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setHamMode(v) + } + }() + case 19: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getNodeRemoteHardwarePinsRequest(v) + } + }() + case 20: try { + var v: NodeRemoteHardwarePinsResponse? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .getNodeRemoteHardwarePinsResponse(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getNodeRemoteHardwarePinsResponse(v) + } + }() + case 21: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .enterDfuModeRequest(v) + } + }() + case 22: try { + var v: String? + try decoder.decodeSingularStringField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .deleteFileRequest(v) + } + }() + case 23: try { + var v: UInt32? + try decoder.decodeSingularUInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setScale(v) + } + }() + case 24: try { + var v: AdminMessage.BackupLocation? + try decoder.decodeSingularEnumField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .backupPreferences(v) + } + }() + case 25: try { + var v: AdminMessage.BackupLocation? + try decoder.decodeSingularEnumField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .restorePreferences(v) + } + }() + case 26: try { + var v: AdminMessage.BackupLocation? + try decoder.decodeSingularEnumField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .removeBackupPreferences(v) + } + }() + case 27: try { + var v: AdminMessage.InputEvent? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .sendInputEvent(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .sendInputEvent(v) + } + }() + case 32: try { + var v: User? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .setOwner(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setOwner(v) + } + }() + case 33: try { + var v: Channel? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .setChannel(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setChannel(v) + } + }() + case 34: try { + var v: Config? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .setConfig(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setConfig(v) + } + }() + case 35: try { + var v: ModuleConfig? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .setModuleConfig(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setModuleConfig(v) + } + }() + case 36: try { + var v: String? + try decoder.decodeSingularStringField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setCannedMessageModuleMessages(v) + } + }() + case 37: try { + var v: String? + try decoder.decodeSingularStringField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setRingtoneMessage(v) + } + }() + case 38: try { + var v: UInt32? + try decoder.decodeSingularUInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .removeByNodenum(v) + } + }() + case 39: try { + var v: UInt32? + try decoder.decodeSingularUInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setFavoriteNode(v) + } + }() + case 40: try { + var v: UInt32? + try decoder.decodeSingularUInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .removeFavoriteNode(v) + } + }() + case 41: try { + var v: Position? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .setFixedPosition(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setFixedPosition(v) + } + }() + case 42: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .removeFixedPosition(v) + } + }() + case 43: try { + var v: UInt32? + try decoder.decodeSingularFixed32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setTimeOnly(v) + } + }() + case 44: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getUiConfigRequest(v) + } + }() + case 45: try { + var v: DeviceUIConfig? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .getUiConfigResponse(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .getUiConfigResponse(v) + } + }() + case 46: try { + var v: DeviceUIConfig? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .storeUiConfig(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .storeUiConfig(v) + } + }() + case 47: try { + var v: UInt32? + try decoder.decodeSingularUInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .setIgnoredNode(v) + } + }() + case 48: try { + var v: UInt32? + try decoder.decodeSingularUInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .removeIgnoredNode(v) + } + }() + case 49: try { + var v: UInt32? + try decoder.decodeSingularUInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .toggleMutedNode(v) + } + }() + case 64: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .beginEditSettings(v) + } + }() + case 65: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .commitEditSettings(v) + } + }() + case 66: try { + var v: SharedContact? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .addContact(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .addContact(v) + } + }() + case 67: try { + var v: KeyVerificationAdmin? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .keyVerification(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .keyVerification(v) + } + }() + case 94: try { + var v: Int32? + try decoder.decodeSingularInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .factoryResetDevice(v) + } + }() + case 95: try { + var v: Int32? + try decoder.decodeSingularInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .rebootOtaSeconds(v) + } + }() + case 96: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .exitSimulator(v) + } + }() + case 97: try { + var v: Int32? + try decoder.decodeSingularInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .rebootSeconds(v) + } + }() + case 98: try { + var v: Int32? + try decoder.decodeSingularInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .shutdownSeconds(v) + } + }() + case 99: try { + var v: Int32? + try decoder.decodeSingularInt32Field(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .factoryResetConfig(v) + } + }() + case 100: try { + var v: Bool? + try decoder.decodeSingularBoolField(value: &v) + if let v = v { + if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .nodedbReset(v) + } + }() + case 101: try { try decoder.decodeSingularBytesField(value: &_storage._sessionPasskey) }() + case 102: try { + var v: AdminMessage.OTAEvent? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .otaRequest(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .otaRequest(v) + } + }() + case 103: try { + var v: SensorConfig? + var hadOneofValue = false + if let current = _storage._payloadVariant { + hadOneofValue = true + if case .sensorConfig(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._payloadVariant = .sensorConfig(v) + } + }() + default: break } - }() - case 2: try { - var v: Channel? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .getChannelResponse(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getChannelResponse(v) - } - }() - case 3: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getOwnerRequest(v) - } - }() - case 4: try { - var v: User? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .getOwnerResponse(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getOwnerResponse(v) - } - }() - case 5: try { - var v: AdminMessage.ConfigType? - try decoder.decodeSingularEnumField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getConfigRequest(v) - } - }() - case 6: try { - var v: Config? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .getConfigResponse(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getConfigResponse(v) - } - }() - case 7: try { - var v: AdminMessage.ModuleConfigType? - try decoder.decodeSingularEnumField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getModuleConfigRequest(v) - } - }() - case 8: try { - var v: ModuleConfig? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .getModuleConfigResponse(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getModuleConfigResponse(v) - } - }() - case 10: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getCannedMessageModuleMessagesRequest(v) - } - }() - case 11: try { - var v: String? - try decoder.decodeSingularStringField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getCannedMessageModuleMessagesResponse(v) - } - }() - case 12: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getDeviceMetadataRequest(v) - } - }() - case 13: try { - var v: DeviceMetadata? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .getDeviceMetadataResponse(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getDeviceMetadataResponse(v) - } - }() - case 14: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getRingtoneRequest(v) - } - }() - case 15: try { - var v: String? - try decoder.decodeSingularStringField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getRingtoneResponse(v) - } - }() - case 16: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getDeviceConnectionStatusRequest(v) - } - }() - case 17: try { - var v: DeviceConnectionStatus? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .getDeviceConnectionStatusResponse(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getDeviceConnectionStatusResponse(v) - } - }() - case 18: try { - var v: HamParameters? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .setHamMode(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setHamMode(v) - } - }() - case 19: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getNodeRemoteHardwarePinsRequest(v) - } - }() - case 20: try { - var v: NodeRemoteHardwarePinsResponse? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .getNodeRemoteHardwarePinsResponse(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getNodeRemoteHardwarePinsResponse(v) - } - }() - case 21: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .enterDfuModeRequest(v) - } - }() - case 22: try { - var v: String? - try decoder.decodeSingularStringField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .deleteFileRequest(v) - } - }() - case 23: try { - var v: UInt32? - try decoder.decodeSingularUInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setScale(v) - } - }() - case 24: try { - var v: AdminMessage.BackupLocation? - try decoder.decodeSingularEnumField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .backupPreferences(v) - } - }() - case 25: try { - var v: AdminMessage.BackupLocation? - try decoder.decodeSingularEnumField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .restorePreferences(v) - } - }() - case 26: try { - var v: AdminMessage.BackupLocation? - try decoder.decodeSingularEnumField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .removeBackupPreferences(v) - } - }() - case 27: try { - var v: AdminMessage.InputEvent? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .sendInputEvent(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .sendInputEvent(v) - } - }() - case 32: try { - var v: User? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .setOwner(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setOwner(v) - } - }() - case 33: try { - var v: Channel? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .setChannel(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setChannel(v) - } - }() - case 34: try { - var v: Config? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .setConfig(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setConfig(v) - } - }() - case 35: try { - var v: ModuleConfig? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .setModuleConfig(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setModuleConfig(v) - } - }() - case 36: try { - var v: String? - try decoder.decodeSingularStringField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setCannedMessageModuleMessages(v) - } - }() - case 37: try { - var v: String? - try decoder.decodeSingularStringField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setRingtoneMessage(v) - } - }() - case 38: try { - var v: UInt32? - try decoder.decodeSingularUInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .removeByNodenum(v) - } - }() - case 39: try { - var v: UInt32? - try decoder.decodeSingularUInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setFavoriteNode(v) - } - }() - case 40: try { - var v: UInt32? - try decoder.decodeSingularUInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .removeFavoriteNode(v) - } - }() - case 41: try { - var v: Position? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .setFixedPosition(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setFixedPosition(v) - } - }() - case 42: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .removeFixedPosition(v) - } - }() - case 43: try { - var v: UInt32? - try decoder.decodeSingularFixed32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setTimeOnly(v) - } - }() - case 44: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getUiConfigRequest(v) - } - }() - case 45: try { - var v: DeviceUIConfig? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .getUiConfigResponse(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .getUiConfigResponse(v) - } - }() - case 46: try { - var v: DeviceUIConfig? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .storeUiConfig(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .storeUiConfig(v) - } - }() - case 47: try { - var v: UInt32? - try decoder.decodeSingularUInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .setIgnoredNode(v) - } - }() - case 48: try { - var v: UInt32? - try decoder.decodeSingularUInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .removeIgnoredNode(v) - } - }() - case 49: try { - var v: UInt32? - try decoder.decodeSingularUInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .toggleMutedNode(v) - } - }() - case 64: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .beginEditSettings(v) - } - }() - case 65: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .commitEditSettings(v) - } - }() - case 66: try { - var v: SharedContact? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .addContact(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .addContact(v) - } - }() - case 67: try { - var v: KeyVerificationAdmin? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .keyVerification(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .keyVerification(v) - } - }() - case 94: try { - var v: Int32? - try decoder.decodeSingularInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .factoryResetDevice(v) - } - }() - case 95: try { - var v: Int32? - try decoder.decodeSingularInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .rebootOtaSeconds(v) - } - }() - case 96: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .exitSimulator(v) - } - }() - case 97: try { - var v: Int32? - try decoder.decodeSingularInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .rebootSeconds(v) - } - }() - case 98: try { - var v: Int32? - try decoder.decodeSingularInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .shutdownSeconds(v) - } - }() - case 99: try { - var v: Int32? - try decoder.decodeSingularInt32Field(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .factoryResetConfig(v) - } - }() - case 100: try { - var v: Bool? - try decoder.decodeSingularBoolField(value: &v) - if let v = v { - if self.payloadVariant != nil {try decoder.handleConflictingOneOf()} - self.payloadVariant = .nodedbReset(v) - } - }() - case 101: try { try decoder.decodeSingularBytesField(value: &self.sessionPasskey) }() - case 102: try { - var v: AdminMessage.OTAEvent? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .otaRequest(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .otaRequest(v) - } - }() - case 103: try { - var v: SensorConfig? - var hadOneofValue = false - if let current = self.payloadVariant { - hadOneofValue = true - if case .sensorConfig(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.payloadVariant = .sensorConfig(v) - } - }() - default: break } } } public func traverse(visitor: inout V) throws { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every if/case branch local when no optimizations - // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and - // https://github.com/apple/swift-protobuf/issues/1182 - switch self.payloadVariant { - case .getChannelRequest?: try { - guard case .getChannelRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularUInt32Field(value: v, fieldNumber: 1) - }() - case .getChannelResponse?: try { - guard case .getChannelResponse(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 2) - }() - case .getOwnerRequest?: try { - guard case .getOwnerRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 3) - }() - case .getOwnerResponse?: try { - guard case .getOwnerResponse(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 4) - }() - case .getConfigRequest?: try { - guard case .getConfigRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularEnumField(value: v, fieldNumber: 5) - }() - case .getConfigResponse?: try { - guard case .getConfigResponse(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 6) - }() - case .getModuleConfigRequest?: try { - guard case .getModuleConfigRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularEnumField(value: v, fieldNumber: 7) - }() - case .getModuleConfigResponse?: try { - guard case .getModuleConfigResponse(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 8) - }() - case .getCannedMessageModuleMessagesRequest?: try { - guard case .getCannedMessageModuleMessagesRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 10) - }() - case .getCannedMessageModuleMessagesResponse?: try { - guard case .getCannedMessageModuleMessagesResponse(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularStringField(value: v, fieldNumber: 11) - }() - case .getDeviceMetadataRequest?: try { - guard case .getDeviceMetadataRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 12) - }() - case .getDeviceMetadataResponse?: try { - guard case .getDeviceMetadataResponse(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 13) - }() - case .getRingtoneRequest?: try { - guard case .getRingtoneRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 14) - }() - case .getRingtoneResponse?: try { - guard case .getRingtoneResponse(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularStringField(value: v, fieldNumber: 15) - }() - case .getDeviceConnectionStatusRequest?: try { - guard case .getDeviceConnectionStatusRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 16) - }() - case .getDeviceConnectionStatusResponse?: try { - guard case .getDeviceConnectionStatusResponse(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 17) - }() - case .setHamMode?: try { - guard case .setHamMode(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 18) - }() - case .getNodeRemoteHardwarePinsRequest?: try { - guard case .getNodeRemoteHardwarePinsRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 19) - }() - case .getNodeRemoteHardwarePinsResponse?: try { - guard case .getNodeRemoteHardwarePinsResponse(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 20) - }() - case .enterDfuModeRequest?: try { - guard case .enterDfuModeRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 21) - }() - case .deleteFileRequest?: try { - guard case .deleteFileRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularStringField(value: v, fieldNumber: 22) - }() - case .setScale?: try { - guard case .setScale(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularUInt32Field(value: v, fieldNumber: 23) - }() - case .backupPreferences?: try { - guard case .backupPreferences(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularEnumField(value: v, fieldNumber: 24) - }() - case .restorePreferences?: try { - guard case .restorePreferences(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularEnumField(value: v, fieldNumber: 25) - }() - case .removeBackupPreferences?: try { - guard case .removeBackupPreferences(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularEnumField(value: v, fieldNumber: 26) - }() - case .sendInputEvent?: try { - guard case .sendInputEvent(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 27) - }() - case .setOwner?: try { - guard case .setOwner(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 32) - }() - case .setChannel?: try { - guard case .setChannel(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 33) - }() - case .setConfig?: try { - guard case .setConfig(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 34) - }() - case .setModuleConfig?: try { - guard case .setModuleConfig(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 35) - }() - case .setCannedMessageModuleMessages?: try { - guard case .setCannedMessageModuleMessages(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularStringField(value: v, fieldNumber: 36) - }() - case .setRingtoneMessage?: try { - guard case .setRingtoneMessage(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularStringField(value: v, fieldNumber: 37) - }() - case .removeByNodenum?: try { - guard case .removeByNodenum(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularUInt32Field(value: v, fieldNumber: 38) - }() - case .setFavoriteNode?: try { - guard case .setFavoriteNode(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularUInt32Field(value: v, fieldNumber: 39) - }() - case .removeFavoriteNode?: try { - guard case .removeFavoriteNode(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularUInt32Field(value: v, fieldNumber: 40) - }() - case .setFixedPosition?: try { - guard case .setFixedPosition(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 41) - }() - case .removeFixedPosition?: try { - guard case .removeFixedPosition(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 42) - }() - case .setTimeOnly?: try { - guard case .setTimeOnly(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularFixed32Field(value: v, fieldNumber: 43) - }() - case .getUiConfigRequest?: try { - guard case .getUiConfigRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 44) - }() - case .getUiConfigResponse?: try { - guard case .getUiConfigResponse(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 45) - }() - case .storeUiConfig?: try { - guard case .storeUiConfig(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 46) - }() - case .setIgnoredNode?: try { - guard case .setIgnoredNode(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularUInt32Field(value: v, fieldNumber: 47) - }() - case .removeIgnoredNode?: try { - guard case .removeIgnoredNode(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularUInt32Field(value: v, fieldNumber: 48) - }() - case .toggleMutedNode?: try { - guard case .toggleMutedNode(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularUInt32Field(value: v, fieldNumber: 49) - }() - case .beginEditSettings?: try { - guard case .beginEditSettings(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 64) - }() - case .commitEditSettings?: try { - guard case .commitEditSettings(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 65) - }() - case .addContact?: try { - guard case .addContact(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 66) - }() - case .keyVerification?: try { - guard case .keyVerification(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 67) - }() - case .factoryResetDevice?: try { - guard case .factoryResetDevice(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularInt32Field(value: v, fieldNumber: 94) - }() - case .rebootOtaSeconds?: try { - guard case .rebootOtaSeconds(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularInt32Field(value: v, fieldNumber: 95) - }() - case .exitSimulator?: try { - guard case .exitSimulator(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 96) - }() - case .rebootSeconds?: try { - guard case .rebootSeconds(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularInt32Field(value: v, fieldNumber: 97) - }() - case .shutdownSeconds?: try { - guard case .shutdownSeconds(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularInt32Field(value: v, fieldNumber: 98) - }() - case .factoryResetConfig?: try { - guard case .factoryResetConfig(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularInt32Field(value: v, fieldNumber: 99) - }() - case .nodedbReset?: try { - guard case .nodedbReset(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularBoolField(value: v, fieldNumber: 100) - }() - default: break - } - if !self.sessionPasskey.isEmpty { - try visitor.visitSingularBytesField(value: self.sessionPasskey, fieldNumber: 101) - } - switch self.payloadVariant { - case .otaRequest?: try { - guard case .otaRequest(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 102) - }() - case .sensorConfig?: try { - guard case .sensorConfig(let v)? = self.payloadVariant else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 103) - }() - default: break + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + switch _storage._payloadVariant { + case .getChannelRequest?: try { + guard case .getChannelRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 1) + }() + case .getChannelResponse?: try { + guard case .getChannelResponse(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case .getOwnerRequest?: try { + guard case .getOwnerRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 3) + }() + case .getOwnerResponse?: try { + guard case .getOwnerResponse(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + }() + case .getConfigRequest?: try { + guard case .getConfigRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularEnumField(value: v, fieldNumber: 5) + }() + case .getConfigResponse?: try { + guard case .getConfigResponse(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + }() + case .getModuleConfigRequest?: try { + guard case .getModuleConfigRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularEnumField(value: v, fieldNumber: 7) + }() + case .getModuleConfigResponse?: try { + guard case .getModuleConfigResponse(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 8) + }() + case .getCannedMessageModuleMessagesRequest?: try { + guard case .getCannedMessageModuleMessagesRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 10) + }() + case .getCannedMessageModuleMessagesResponse?: try { + guard case .getCannedMessageModuleMessagesResponse(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularStringField(value: v, fieldNumber: 11) + }() + case .getDeviceMetadataRequest?: try { + guard case .getDeviceMetadataRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 12) + }() + case .getDeviceMetadataResponse?: try { + guard case .getDeviceMetadataResponse(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 13) + }() + case .getRingtoneRequest?: try { + guard case .getRingtoneRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 14) + }() + case .getRingtoneResponse?: try { + guard case .getRingtoneResponse(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularStringField(value: v, fieldNumber: 15) + }() + case .getDeviceConnectionStatusRequest?: try { + guard case .getDeviceConnectionStatusRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 16) + }() + case .getDeviceConnectionStatusResponse?: try { + guard case .getDeviceConnectionStatusResponse(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 17) + }() + case .setHamMode?: try { + guard case .setHamMode(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 18) + }() + case .getNodeRemoteHardwarePinsRequest?: try { + guard case .getNodeRemoteHardwarePinsRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 19) + }() + case .getNodeRemoteHardwarePinsResponse?: try { + guard case .getNodeRemoteHardwarePinsResponse(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 20) + }() + case .enterDfuModeRequest?: try { + guard case .enterDfuModeRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 21) + }() + case .deleteFileRequest?: try { + guard case .deleteFileRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularStringField(value: v, fieldNumber: 22) + }() + case .setScale?: try { + guard case .setScale(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 23) + }() + case .backupPreferences?: try { + guard case .backupPreferences(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularEnumField(value: v, fieldNumber: 24) + }() + case .restorePreferences?: try { + guard case .restorePreferences(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularEnumField(value: v, fieldNumber: 25) + }() + case .removeBackupPreferences?: try { + guard case .removeBackupPreferences(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularEnumField(value: v, fieldNumber: 26) + }() + case .sendInputEvent?: try { + guard case .sendInputEvent(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 27) + }() + case .setOwner?: try { + guard case .setOwner(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 32) + }() + case .setChannel?: try { + guard case .setChannel(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 33) + }() + case .setConfig?: try { + guard case .setConfig(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 34) + }() + case .setModuleConfig?: try { + guard case .setModuleConfig(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 35) + }() + case .setCannedMessageModuleMessages?: try { + guard case .setCannedMessageModuleMessages(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularStringField(value: v, fieldNumber: 36) + }() + case .setRingtoneMessage?: try { + guard case .setRingtoneMessage(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularStringField(value: v, fieldNumber: 37) + }() + case .removeByNodenum?: try { + guard case .removeByNodenum(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 38) + }() + case .setFavoriteNode?: try { + guard case .setFavoriteNode(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 39) + }() + case .removeFavoriteNode?: try { + guard case .removeFavoriteNode(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 40) + }() + case .setFixedPosition?: try { + guard case .setFixedPosition(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 41) + }() + case .removeFixedPosition?: try { + guard case .removeFixedPosition(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 42) + }() + case .setTimeOnly?: try { + guard case .setTimeOnly(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularFixed32Field(value: v, fieldNumber: 43) + }() + case .getUiConfigRequest?: try { + guard case .getUiConfigRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 44) + }() + case .getUiConfigResponse?: try { + guard case .getUiConfigResponse(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 45) + }() + case .storeUiConfig?: try { + guard case .storeUiConfig(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 46) + }() + case .setIgnoredNode?: try { + guard case .setIgnoredNode(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 47) + }() + case .removeIgnoredNode?: try { + guard case .removeIgnoredNode(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 48) + }() + case .toggleMutedNode?: try { + guard case .toggleMutedNode(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 49) + }() + case .beginEditSettings?: try { + guard case .beginEditSettings(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 64) + }() + case .commitEditSettings?: try { + guard case .commitEditSettings(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 65) + }() + case .addContact?: try { + guard case .addContact(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 66) + }() + case .keyVerification?: try { + guard case .keyVerification(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 67) + }() + case .factoryResetDevice?: try { + guard case .factoryResetDevice(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularInt32Field(value: v, fieldNumber: 94) + }() + case .rebootOtaSeconds?: try { + guard case .rebootOtaSeconds(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularInt32Field(value: v, fieldNumber: 95) + }() + case .exitSimulator?: try { + guard case .exitSimulator(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 96) + }() + case .rebootSeconds?: try { + guard case .rebootSeconds(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularInt32Field(value: v, fieldNumber: 97) + }() + case .shutdownSeconds?: try { + guard case .shutdownSeconds(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularInt32Field(value: v, fieldNumber: 98) + }() + case .factoryResetConfig?: try { + guard case .factoryResetConfig(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularInt32Field(value: v, fieldNumber: 99) + }() + case .nodedbReset?: try { + guard case .nodedbReset(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularBoolField(value: v, fieldNumber: 100) + }() + default: break + } + if !_storage._sessionPasskey.isEmpty { + try visitor.visitSingularBytesField(value: _storage._sessionPasskey, fieldNumber: 101) + } + switch _storage._payloadVariant { + case .otaRequest?: try { + guard case .otaRequest(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 102) + }() + case .sensorConfig?: try { + guard case .sensorConfig(let v)? = _storage._payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 103) + }() + default: break + } } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: AdminMessage, rhs: AdminMessage) -> Bool { - if lhs.sessionPasskey != rhs.sessionPasskey {return false} - if lhs.payloadVariant != rhs.payloadVariant {return false} + if lhs._storage !== rhs._storage { + let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in + let _storage = _args.0 + let rhs_storage = _args.1 + if _storage._sessionPasskey != rhs_storage._sessionPasskey {return false} + if _storage._payloadVariant != rhs_storage._payloadVariant {return false} + return true + } + if !storagesAreEqual {return false} + } if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -2368,7 +2551,7 @@ extension AdminMessage.ConfigType: SwiftProtobuf._ProtoNameProviding { } extension AdminMessage.ModuleConfigType: SwiftProtobuf._ProtoNameProviding { - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0MQTT_CONFIG\0\u{1}SERIAL_CONFIG\0\u{1}EXTNOTIF_CONFIG\0\u{1}STOREFORWARD_CONFIG\0\u{1}RANGETEST_CONFIG\0\u{1}TELEMETRY_CONFIG\0\u{1}CANNEDMSG_CONFIG\0\u{1}AUDIO_CONFIG\0\u{1}REMOTEHARDWARE_CONFIG\0\u{1}NEIGHBORINFO_CONFIG\0\u{1}AMBIENTLIGHTING_CONFIG\0\u{1}DETECTIONSENSOR_CONFIG\0\u{1}PAXCOUNTER_CONFIG\0\u{1}STATUSMESSAGE_CONFIG\0\u{1}TRAFFICMANAGEMENT_CONFIG\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0MQTT_CONFIG\0\u{1}SERIAL_CONFIG\0\u{1}EXTNOTIF_CONFIG\0\u{1}STOREFORWARD_CONFIG\0\u{1}RANGETEST_CONFIG\0\u{1}TELEMETRY_CONFIG\0\u{1}CANNEDMSG_CONFIG\0\u{1}AUDIO_CONFIG\0\u{1}REMOTEHARDWARE_CONFIG\0\u{1}NEIGHBORINFO_CONFIG\0\u{1}AMBIENTLIGHTING_CONFIG\0\u{1}DETECTIONSENSOR_CONFIG\0\u{1}PAXCOUNTER_CONFIG\0\u{1}STATUSMESSAGE_CONFIG\0\u{1}TRAFFICMANAGEMENT_CONFIG\0\u{1}TAK_CONFIG\0") } extension AdminMessage.BackupLocation: SwiftProtobuf._ProtoNameProviding { @@ -2634,7 +2817,7 @@ extension KeyVerificationAdmin.MessageType: SwiftProtobuf._ProtoNameProviding { extension SensorConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SensorConfig" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}scd4x_config\0\u{3}sen5x_config\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}scd4x_config\0\u{3}sen5x_config\0\u{3}scd30_config\0\u{3}shtxx_config\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -2644,6 +2827,8 @@ extension SensorConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat switch fieldNumber { case 1: try { try decoder.decodeSingularMessageField(value: &self._scd4XConfig) }() case 2: try { try decoder.decodeSingularMessageField(value: &self._sen5XConfig) }() + case 3: try { try decoder.decodeSingularMessageField(value: &self._scd30Config) }() + case 4: try { try decoder.decodeSingularMessageField(value: &self._shtxxConfig) }() default: break } } @@ -2660,12 +2845,20 @@ extension SensorConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat try { if let v = self._sen5XConfig { try visitor.visitSingularMessageField(value: v, fieldNumber: 2) } }() + try { if let v = self._scd30Config { + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + } }() + try { if let v = self._shtxxConfig { + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: SensorConfig, rhs: SensorConfig) -> Bool { if lhs._scd4XConfig != rhs._scd4XConfig {return false} if lhs._sen5XConfig != rhs._sen5XConfig {return false} + if lhs._scd30Config != rhs._scd30Config {return false} + if lhs._shtxxConfig != rhs._shtxxConfig {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -2773,3 +2966,96 @@ extension SEN5X_config: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat return true } } + +extension SCD30_config: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".SCD30_config" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}set_asc\0\u{3}set_target_co2_conc\0\u{3}set_temperature\0\u{3}set_altitude\0\u{3}set_measurement_interval\0\u{3}soft_reset\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularBoolField(value: &self._setAsc) }() + case 2: try { try decoder.decodeSingularUInt32Field(value: &self._setTargetCo2Conc) }() + case 3: try { try decoder.decodeSingularFloatField(value: &self._setTemperature) }() + case 4: try { try decoder.decodeSingularUInt32Field(value: &self._setAltitude) }() + case 5: try { try decoder.decodeSingularUInt32Field(value: &self._setMeasurementInterval) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self._softReset) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._setAsc { + try visitor.visitSingularBoolField(value: v, fieldNumber: 1) + } }() + try { if let v = self._setTargetCo2Conc { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 2) + } }() + try { if let v = self._setTemperature { + try visitor.visitSingularFloatField(value: v, fieldNumber: 3) + } }() + try { if let v = self._setAltitude { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 4) + } }() + try { if let v = self._setMeasurementInterval { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 5) + } }() + try { if let v = self._softReset { + try visitor.visitSingularBoolField(value: v, fieldNumber: 6) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: SCD30_config, rhs: SCD30_config) -> Bool { + if lhs._setAsc != rhs._setAsc {return false} + if lhs._setTargetCo2Conc != rhs._setTargetCo2Conc {return false} + if lhs._setTemperature != rhs._setTemperature {return false} + if lhs._setAltitude != rhs._setAltitude {return false} + if lhs._setMeasurementInterval != rhs._setMeasurementInterval {return false} + if lhs._softReset != rhs._softReset {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension SHTXX_config: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".SHTXX_config" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}set_accuracy\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self._setAccuracy) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._setAccuracy { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 1) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: SHTXX_config, rhs: SHTXX_config) -> Bool { + if lhs._setAccuracy != rhs._setAccuracy {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/MeshtasticProtobufs/Sources/meshtastic/config.pb.swift b/MeshtasticProtobufs/Sources/meshtastic/config.pb.swift index 943c2d2c..4a1cb3d4 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/config.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/config.pb.swift @@ -1478,6 +1478,13 @@ public struct Config: Sendable { set {_uniqueStorage()._configOkToMqtt = newValue} } + /// + /// Set where LORA FEM is enabled, disabled, or not present + public var femLnaMode: Config.LoRaConfig.FEM_LNA_Mode { + get {return _storage._femLnaMode} + set {_uniqueStorage()._femLnaMode = newValue} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() public enum RegionCode: SwiftProtobuf.Enum, Swift.CaseIterable { @@ -1803,6 +1810,53 @@ public struct Config: Sendable { } + public enum FEM_LNA_Mode: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + + /// + /// FEM_LNA is present but disabled + case disabled // = 0 + + /// + /// FEM_LNA is present and enabled + case enabled // = 1 + + /// + /// FEM_LNA is not present on the device + case notPresent // = 2 + case UNRECOGNIZED(Int) + + public init() { + self = .disabled + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .disabled + case 1: self = .enabled + case 2: self = .notPresent + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .disabled: return 0 + case .enabled: return 1 + case .notPresent: return 2 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Config.LoRaConfig.FEM_LNA_Mode] = [ + .disabled, + .enabled, + .notPresent, + ] + + } + public init() {} fileprivate var _storage = _StorageClass.defaultInstance @@ -2655,7 +2709,7 @@ extension Config.DisplayConfig.CompassOrientation: SwiftProtobuf._ProtoNameProvi extension Config.LoRaConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = Config.protoMessageName + ".LoRaConfig" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}use_preset\0\u{3}modem_preset\0\u{1}bandwidth\0\u{3}spread_factor\0\u{3}coding_rate\0\u{3}frequency_offset\0\u{1}region\0\u{3}hop_limit\0\u{3}tx_enabled\0\u{3}tx_power\0\u{3}channel_num\0\u{3}override_duty_cycle\0\u{3}sx126x_rx_boosted_gain\0\u{3}override_frequency\0\u{3}pa_fan_disabled\0\u{4}X\u{1}ignore_incoming\0\u{3}ignore_mqtt\0\u{3}config_ok_to_mqtt\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}use_preset\0\u{3}modem_preset\0\u{1}bandwidth\0\u{3}spread_factor\0\u{3}coding_rate\0\u{3}frequency_offset\0\u{1}region\0\u{3}hop_limit\0\u{3}tx_enabled\0\u{3}tx_power\0\u{3}channel_num\0\u{3}override_duty_cycle\0\u{3}sx126x_rx_boosted_gain\0\u{3}override_frequency\0\u{3}pa_fan_disabled\0\u{4}X\u{1}ignore_incoming\0\u{3}ignore_mqtt\0\u{3}config_ok_to_mqtt\0\u{3}fem_lna_mode\0") fileprivate class _StorageClass { var _usePreset: Bool = false @@ -2676,6 +2730,7 @@ extension Config.LoRaConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem var _ignoreIncoming: [UInt32] = [] var _ignoreMqtt: Bool = false var _configOkToMqtt: Bool = false + var _femLnaMode: Config.LoRaConfig.FEM_LNA_Mode = .disabled // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. @@ -2704,6 +2759,7 @@ extension Config.LoRaConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem _ignoreIncoming = source._ignoreIncoming _ignoreMqtt = source._ignoreMqtt _configOkToMqtt = source._configOkToMqtt + _femLnaMode = source._femLnaMode } } @@ -2740,6 +2796,7 @@ extension Config.LoRaConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem case 103: try { try decoder.decodeRepeatedUInt32Field(value: &_storage._ignoreIncoming) }() case 104: try { try decoder.decodeSingularBoolField(value: &_storage._ignoreMqtt) }() case 105: try { try decoder.decodeSingularBoolField(value: &_storage._configOkToMqtt) }() + case 106: try { try decoder.decodeSingularEnumField(value: &_storage._femLnaMode) }() default: break } } @@ -2802,6 +2859,9 @@ extension Config.LoRaConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem if _storage._configOkToMqtt != false { try visitor.visitSingularBoolField(value: _storage._configOkToMqtt, fieldNumber: 105) } + if _storage._femLnaMode != .disabled { + try visitor.visitSingularEnumField(value: _storage._femLnaMode, fieldNumber: 106) + } } try unknownFields.traverse(visitor: &visitor) } @@ -2829,6 +2889,7 @@ extension Config.LoRaConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem if _storage._ignoreIncoming != rhs_storage._ignoreIncoming {return false} if _storage._ignoreMqtt != rhs_storage._ignoreMqtt {return false} if _storage._configOkToMqtt != rhs_storage._configOkToMqtt {return false} + if _storage._femLnaMode != rhs_storage._femLnaMode {return false} return true } if !storagesAreEqual {return false} @@ -2846,6 +2907,10 @@ extension Config.LoRaConfig.ModemPreset: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0LONG_FAST\0\u{1}LONG_SLOW\0\u{1}VERY_LONG_SLOW\0\u{1}MEDIUM_SLOW\0\u{1}MEDIUM_FAST\0\u{1}SHORT_SLOW\0\u{1}SHORT_FAST\0\u{1}LONG_MODERATE\0\u{1}SHORT_TURBO\0\u{1}LONG_TURBO\0") } +extension Config.LoRaConfig.FEM_LNA_Mode: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0DISABLED\0\u{1}ENABLED\0\u{1}NOT_PRESENT\0") +} + extension Config.BluetoothConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = Config.protoMessageName + ".BluetoothConfig" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}enabled\0\u{1}mode\0\u{3}fixed_pin\0") diff --git a/MeshtasticProtobufs/Sources/meshtastic/localonly.pb.swift b/MeshtasticProtobufs/Sources/meshtastic/localonly.pb.swift index 91874766..94630b12 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/localonly.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/localonly.pb.swift @@ -299,6 +299,17 @@ public struct LocalModuleConfig: @unchecked Sendable { /// Clears the value of `trafficManagement`. Subsequent reads from it will return its default value. public mutating func clearTrafficManagement() {_uniqueStorage()._trafficManagement = nil} + /// + /// TAK Config + public var tak: ModuleConfig.TAKConfig { + get {return _storage._tak ?? ModuleConfig.TAKConfig()} + set {_uniqueStorage()._tak = newValue} + } + /// Returns true if `tak` has been explicitly set. + public var hasTak: Bool {return _storage._tak != nil} + /// Clears the value of `tak`. Subsequent reads from it will return its default value. + public mutating func clearTak() {_uniqueStorage()._tak = nil} + /// /// A version integer used to invalidate old save files when we make /// incompatible changes This integer is set at build time and is private to @@ -447,7 +458,7 @@ extension LocalConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementati extension LocalModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".LocalModuleConfig" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}mqtt\0\u{1}serial\0\u{3}external_notification\0\u{3}store_forward\0\u{3}range_test\0\u{1}telemetry\0\u{3}canned_message\0\u{1}version\0\u{1}audio\0\u{3}remote_hardware\0\u{3}neighbor_info\0\u{3}ambient_lighting\0\u{3}detection_sensor\0\u{1}paxcounter\0\u{1}statusmessage\0\u{3}traffic_management\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}mqtt\0\u{1}serial\0\u{3}external_notification\0\u{3}store_forward\0\u{3}range_test\0\u{1}telemetry\0\u{3}canned_message\0\u{1}version\0\u{1}audio\0\u{3}remote_hardware\0\u{3}neighbor_info\0\u{3}ambient_lighting\0\u{3}detection_sensor\0\u{1}paxcounter\0\u{1}statusmessage\0\u{3}traffic_management\0\u{1}tak\0") fileprivate class _StorageClass { var _mqtt: ModuleConfig.MQTTConfig? = nil @@ -465,6 +476,7 @@ extension LocalModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem var _paxcounter: ModuleConfig.PaxcounterConfig? = nil var _statusmessage: ModuleConfig.StatusMessageConfig? = nil var _trafficManagement: ModuleConfig.TrafficManagementConfig? = nil + var _tak: ModuleConfig.TAKConfig? = nil var _version: UInt32 = 0 // This property is used as the initial default value for new instances of the type. @@ -491,6 +503,7 @@ extension LocalModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem _paxcounter = source._paxcounter _statusmessage = source._statusmessage _trafficManagement = source._trafficManagement + _tak = source._tak _version = source._version } } @@ -526,6 +539,7 @@ extension LocalModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem case 14: try { try decoder.decodeSingularMessageField(value: &_storage._paxcounter) }() case 15: try { try decoder.decodeSingularMessageField(value: &_storage._statusmessage) }() case 16: try { try decoder.decodeSingularMessageField(value: &_storage._trafficManagement) }() + case 17: try { try decoder.decodeSingularMessageField(value: &_storage._tak) }() default: break } } @@ -586,6 +600,9 @@ extension LocalModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem try { if let v = _storage._trafficManagement { try visitor.visitSingularMessageField(value: v, fieldNumber: 16) } }() + try { if let v = _storage._tak { + try visitor.visitSingularMessageField(value: v, fieldNumber: 17) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -610,6 +627,7 @@ extension LocalModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem if _storage._paxcounter != rhs_storage._paxcounter {return false} if _storage._statusmessage != rhs_storage._statusmessage {return false} if _storage._trafficManagement != rhs_storage._trafficManagement {return false} + if _storage._tak != rhs_storage._tak {return false} if _storage._version != rhs_storage._version {return false} return true } diff --git a/MeshtasticProtobufs/Sources/meshtastic/mesh.pb.swift b/MeshtasticProtobufs/Sources/meshtastic/mesh.pb.swift index 99e91556..97160915 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/mesh.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/mesh.pb.swift @@ -547,6 +547,25 @@ public enum HardwareModel: SwiftProtobuf.Enum, Swift.CaseIterable { /// LilyGo T5 S3 ePaper Pro (V1 and V2) case t5S3EpaperPro // = 123 + /// + /// LilyGo T-Beam BPF (144-148Mhz) + case tbeamBpf // = 124 + + /// + /// LilyGo T-Mini E-paper S3 Kit + case miniEpaperS3 // = 125 + + /// + /// LilyGo T-Display S3 Pro LR1121 + case tdisplayS3Pro // = 126 + + /// + /// Heltec Mesh Node T096 board features an nRF52840 CPU and a TFT screen. + case heltecMeshNodeT096 // = 127 + + /// Seeed studio T1000-E Pro tracker card. NRF52840 w/ LR2021 radio, GPS, button, buzzer, and sensors. + case trackerT1000EPro // = 128 + /// /// ------------------------------------------------------------------------------------------------------------------------------------------ /// Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. @@ -684,6 +703,11 @@ public enum HardwareModel: SwiftProtobuf.Enum, Swift.CaseIterable { case 121: self = .meshstick1262 case 122: self = .tbeam1Watt case 123: self = .t5S3EpaperPro + case 124: self = .tbeamBpf + case 125: self = .miniEpaperS3 + case 126: self = .tdisplayS3Pro + case 127: self = .heltecMeshNodeT096 + case 128: self = .trackerT1000EPro case 255: self = .privateHw default: self = .UNRECOGNIZED(rawValue) } @@ -815,6 +839,11 @@ public enum HardwareModel: SwiftProtobuf.Enum, Swift.CaseIterable { case .meshstick1262: return 121 case .tbeam1Watt: return 122 case .t5S3EpaperPro: return 123 + case .tbeamBpf: return 124 + case .miniEpaperS3: return 125 + case .tdisplayS3Pro: return 126 + case .heltecMeshNodeT096: return 127 + case .trackerT1000EPro: return 128 case .privateHw: return 255 case .UNRECOGNIZED(let i): return i } @@ -946,6 +975,11 @@ public enum HardwareModel: SwiftProtobuf.Enum, Swift.CaseIterable { .meshstick1262, .tbeam1Watt, .t5S3EpaperPro, + .tbeamBpf, + .miniEpaperS3, + .tdisplayS3Pro, + .heltecMeshNodeT096, + .trackerT1000EPro, .privateHw, ] @@ -4014,7 +4048,7 @@ public struct ChunkedPayloadResponse: Sendable { fileprivate let _protobuf_package = "meshtastic" extension HardwareModel: SwiftProtobuf._ProtoNameProviding { - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNSET\0\u{1}TLORA_V2\0\u{1}TLORA_V1\0\u{1}TLORA_V2_1_1P6\0\u{1}TBEAM\0\u{1}HELTEC_V2_0\0\u{1}TBEAM_V0P7\0\u{1}T_ECHO\0\u{1}TLORA_V1_1P3\0\u{1}RAK4631\0\u{1}HELTEC_V2_1\0\u{1}HELTEC_V1\0\u{1}LILYGO_TBEAM_S3_CORE\0\u{1}RAK11200\0\u{1}NANO_G1\0\u{1}TLORA_V2_1_1P8\0\u{1}TLORA_T3_S3\0\u{1}NANO_G1_EXPLORER\0\u{1}NANO_G2_ULTRA\0\u{1}LORA_TYPE\0\u{1}WIPHONE\0\u{1}WIO_WM1110\0\u{1}RAK2560\0\u{1}HELTEC_HRU_3601\0\u{1}HELTEC_WIRELESS_BRIDGE\0\u{1}STATION_G1\0\u{1}RAK11310\0\u{1}SENSELORA_RP2040\0\u{1}SENSELORA_S3\0\u{1}CANARYONE\0\u{1}RP2040_LORA\0\u{1}STATION_G2\0\u{1}LORA_RELAY_V1\0\u{1}T_ECHO_PLUS\0\u{1}PPR\0\u{1}GENIEBLOCKS\0\u{1}NRF52_UNKNOWN\0\u{1}PORTDUINO\0\u{1}ANDROID_SIM\0\u{1}DIY_V1\0\u{1}NRF52840_PCA10059\0\u{1}DR_DEV\0\u{1}M5STACK\0\u{1}HELTEC_V3\0\u{1}HELTEC_WSL_V3\0\u{1}BETAFPV_2400_TX\0\u{1}BETAFPV_900_NANO_TX\0\u{1}RPI_PICO\0\u{1}HELTEC_WIRELESS_TRACKER\0\u{1}HELTEC_WIRELESS_PAPER\0\u{1}T_DECK\0\u{1}T_WATCH_S3\0\u{1}PICOMPUTER_S3\0\u{1}HELTEC_HT62\0\u{1}EBYTE_ESP32_S3\0\u{1}ESP32_S3_PICO\0\u{1}CHATTER_2\0\u{1}HELTEC_WIRELESS_PAPER_V1_0\0\u{1}HELTEC_WIRELESS_TRACKER_V1_0\0\u{1}UNPHONE\0\u{1}TD_LORAC\0\u{1}CDEBYTE_EORA_S3\0\u{1}TWC_MESH_V4\0\u{1}NRF52_PROMICRO_DIY\0\u{1}RADIOMASTER_900_BANDIT_NANO\0\u{1}HELTEC_CAPSULE_SENSOR_V3\0\u{1}HELTEC_VISION_MASTER_T190\0\u{1}HELTEC_VISION_MASTER_E213\0\u{1}HELTEC_VISION_MASTER_E290\0\u{1}HELTEC_MESH_NODE_T114\0\u{1}SENSECAP_INDICATOR\0\u{1}TRACKER_T1000_E\0\u{1}RAK3172\0\u{1}WIO_E5\0\u{1}RADIOMASTER_900_BANDIT\0\u{1}ME25LS01_4Y10TD\0\u{1}RP2040_FEATHER_RFM95\0\u{1}M5STACK_COREBASIC\0\u{1}M5STACK_CORE2\0\u{1}RPI_PICO2\0\u{1}M5STACK_CORES3\0\u{1}SEEED_XIAO_S3\0\u{1}MS24SF1\0\u{1}TLORA_C6\0\u{1}WISMESH_TAP\0\u{1}ROUTASTIC\0\u{1}MESH_TAB\0\u{1}MESHLINK\0\u{1}XIAO_NRF52_KIT\0\u{1}THINKNODE_M1\0\u{1}THINKNODE_M2\0\u{1}T_ETH_ELITE\0\u{1}HELTEC_SENSOR_HUB\0\u{1}MUZI_BASE\0\u{1}HELTEC_MESH_POCKET\0\u{1}SEEED_SOLAR_NODE\0\u{1}NOMADSTAR_METEOR_PRO\0\u{1}CROWPANEL\0\u{1}LINK_32\0\u{1}SEEED_WIO_TRACKER_L1\0\u{1}SEEED_WIO_TRACKER_L1_EINK\0\u{1}MUZI_R1_NEO\0\u{1}T_DECK_PRO\0\u{1}T_LORA_PAGER\0\u{1}M5STACK_RESERVED\0\u{1}WISMESH_TAG\0\u{1}RAK3312\0\u{1}THINKNODE_M5\0\u{1}HELTEC_MESH_SOLAR\0\u{1}T_ECHO_LITE\0\u{1}HELTEC_V4\0\u{1}M5STACK_C6L\0\u{1}M5STACK_CARDPUTER_ADV\0\u{1}HELTEC_WIRELESS_TRACKER_V2\0\u{1}T_WATCH_ULTRA\0\u{1}THINKNODE_M3\0\u{1}WISMESH_TAP_V2\0\u{1}RAK3401\0\u{1}RAK6421\0\u{1}THINKNODE_M4\0\u{1}THINKNODE_M6\0\u{1}MESHSTICK_1262\0\u{1}TBEAM_1_WATT\0\u{1}T5_S3_EPAPER_PRO\0\u{2}D\u{2}PRIVATE_HW\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNSET\0\u{1}TLORA_V2\0\u{1}TLORA_V1\0\u{1}TLORA_V2_1_1P6\0\u{1}TBEAM\0\u{1}HELTEC_V2_0\0\u{1}TBEAM_V0P7\0\u{1}T_ECHO\0\u{1}TLORA_V1_1P3\0\u{1}RAK4631\0\u{1}HELTEC_V2_1\0\u{1}HELTEC_V1\0\u{1}LILYGO_TBEAM_S3_CORE\0\u{1}RAK11200\0\u{1}NANO_G1\0\u{1}TLORA_V2_1_1P8\0\u{1}TLORA_T3_S3\0\u{1}NANO_G1_EXPLORER\0\u{1}NANO_G2_ULTRA\0\u{1}LORA_TYPE\0\u{1}WIPHONE\0\u{1}WIO_WM1110\0\u{1}RAK2560\0\u{1}HELTEC_HRU_3601\0\u{1}HELTEC_WIRELESS_BRIDGE\0\u{1}STATION_G1\0\u{1}RAK11310\0\u{1}SENSELORA_RP2040\0\u{1}SENSELORA_S3\0\u{1}CANARYONE\0\u{1}RP2040_LORA\0\u{1}STATION_G2\0\u{1}LORA_RELAY_V1\0\u{1}T_ECHO_PLUS\0\u{1}PPR\0\u{1}GENIEBLOCKS\0\u{1}NRF52_UNKNOWN\0\u{1}PORTDUINO\0\u{1}ANDROID_SIM\0\u{1}DIY_V1\0\u{1}NRF52840_PCA10059\0\u{1}DR_DEV\0\u{1}M5STACK\0\u{1}HELTEC_V3\0\u{1}HELTEC_WSL_V3\0\u{1}BETAFPV_2400_TX\0\u{1}BETAFPV_900_NANO_TX\0\u{1}RPI_PICO\0\u{1}HELTEC_WIRELESS_TRACKER\0\u{1}HELTEC_WIRELESS_PAPER\0\u{1}T_DECK\0\u{1}T_WATCH_S3\0\u{1}PICOMPUTER_S3\0\u{1}HELTEC_HT62\0\u{1}EBYTE_ESP32_S3\0\u{1}ESP32_S3_PICO\0\u{1}CHATTER_2\0\u{1}HELTEC_WIRELESS_PAPER_V1_0\0\u{1}HELTEC_WIRELESS_TRACKER_V1_0\0\u{1}UNPHONE\0\u{1}TD_LORAC\0\u{1}CDEBYTE_EORA_S3\0\u{1}TWC_MESH_V4\0\u{1}NRF52_PROMICRO_DIY\0\u{1}RADIOMASTER_900_BANDIT_NANO\0\u{1}HELTEC_CAPSULE_SENSOR_V3\0\u{1}HELTEC_VISION_MASTER_T190\0\u{1}HELTEC_VISION_MASTER_E213\0\u{1}HELTEC_VISION_MASTER_E290\0\u{1}HELTEC_MESH_NODE_T114\0\u{1}SENSECAP_INDICATOR\0\u{1}TRACKER_T1000_E\0\u{1}RAK3172\0\u{1}WIO_E5\0\u{1}RADIOMASTER_900_BANDIT\0\u{1}ME25LS01_4Y10TD\0\u{1}RP2040_FEATHER_RFM95\0\u{1}M5STACK_COREBASIC\0\u{1}M5STACK_CORE2\0\u{1}RPI_PICO2\0\u{1}M5STACK_CORES3\0\u{1}SEEED_XIAO_S3\0\u{1}MS24SF1\0\u{1}TLORA_C6\0\u{1}WISMESH_TAP\0\u{1}ROUTASTIC\0\u{1}MESH_TAB\0\u{1}MESHLINK\0\u{1}XIAO_NRF52_KIT\0\u{1}THINKNODE_M1\0\u{1}THINKNODE_M2\0\u{1}T_ETH_ELITE\0\u{1}HELTEC_SENSOR_HUB\0\u{1}MUZI_BASE\0\u{1}HELTEC_MESH_POCKET\0\u{1}SEEED_SOLAR_NODE\0\u{1}NOMADSTAR_METEOR_PRO\0\u{1}CROWPANEL\0\u{1}LINK_32\0\u{1}SEEED_WIO_TRACKER_L1\0\u{1}SEEED_WIO_TRACKER_L1_EINK\0\u{1}MUZI_R1_NEO\0\u{1}T_DECK_PRO\0\u{1}T_LORA_PAGER\0\u{1}M5STACK_RESERVED\0\u{1}WISMESH_TAG\0\u{1}RAK3312\0\u{1}THINKNODE_M5\0\u{1}HELTEC_MESH_SOLAR\0\u{1}T_ECHO_LITE\0\u{1}HELTEC_V4\0\u{1}M5STACK_C6L\0\u{1}M5STACK_CARDPUTER_ADV\0\u{1}HELTEC_WIRELESS_TRACKER_V2\0\u{1}T_WATCH_ULTRA\0\u{1}THINKNODE_M3\0\u{1}WISMESH_TAP_V2\0\u{1}RAK3401\0\u{1}RAK6421\0\u{1}THINKNODE_M4\0\u{1}THINKNODE_M6\0\u{1}MESHSTICK_1262\0\u{1}TBEAM_1_WATT\0\u{1}T5_S3_EPAPER_PRO\0\u{1}TBEAM_BPF\0\u{1}MINI_EPAPER_S3\0\u{1}TDISPLAY_S3_PRO\0\u{1}HELTEC_MESH_NODE_T096\0\u{1}TRACKER_T1000_E_PRO\0\u{2}\u{7f}\u{1}PRIVATE_HW\0") } extension Constants: SwiftProtobuf._ProtoNameProviding { diff --git a/MeshtasticProtobufs/Sources/meshtastic/module_config.pb.swift b/MeshtasticProtobufs/Sources/meshtastic/module_config.pb.swift index 4d99c2a1..fd4df99e 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/module_config.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/module_config.pb.swift @@ -228,6 +228,16 @@ public struct ModuleConfig: Sendable { set {payloadVariant = .trafficManagement(newValue)} } + /// + /// TAK team/role configuration for TAK_TRACKER + public var tak: ModuleConfig.TAKConfig { + get { + if case .tak(let v)? = payloadVariant {return v} + return ModuleConfig.TAKConfig() + } + set {payloadVariant = .tak(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() /// @@ -278,6 +288,9 @@ public struct ModuleConfig: Sendable { /// /// Traffic management module config for mesh network optimization case trafficManagement(ModuleConfig.TrafficManagementConfig) + /// + /// TAK team/role configuration for TAK_TRACKER + case tak(ModuleConfig.TAKConfig) } @@ -1391,6 +1404,28 @@ public struct ModuleConfig: Sendable { public init() {} } + /// + /// TAK team/role configuration + public struct TAKConfig: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// + /// Team color. + /// Default Unspecifed_Color -> firmware uses Cyan + public var team: Team = .unspecifedColor + + /// + /// Member role. + /// Default Unspecifed -> firmware uses TeamMember + public var role: MemberRole = .unspecifed + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + } + public init() {} } @@ -1428,7 +1463,7 @@ extension RemoteHardwarePinType: SwiftProtobuf._ProtoNameProviding { extension ModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ModuleConfig" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}mqtt\0\u{1}serial\0\u{3}external_notification\0\u{3}store_forward\0\u{3}range_test\0\u{1}telemetry\0\u{3}canned_message\0\u{1}audio\0\u{3}remote_hardware\0\u{3}neighbor_info\0\u{3}ambient_lighting\0\u{3}detection_sensor\0\u{1}paxcounter\0\u{1}statusmessage\0\u{3}traffic_management\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}mqtt\0\u{1}serial\0\u{3}external_notification\0\u{3}store_forward\0\u{3}range_test\0\u{1}telemetry\0\u{3}canned_message\0\u{1}audio\0\u{3}remote_hardware\0\u{3}neighbor_info\0\u{3}ambient_lighting\0\u{3}detection_sensor\0\u{1}paxcounter\0\u{1}statusmessage\0\u{3}traffic_management\0\u{1}tak\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -1631,6 +1666,19 @@ extension ModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat self.payloadVariant = .trafficManagement(v) } }() + case 16: try { + var v: ModuleConfig.TAKConfig? + var hadOneofValue = false + if let current = self.payloadVariant { + hadOneofValue = true + if case .tak(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.payloadVariant = .tak(v) + } + }() default: break } } @@ -1702,6 +1750,10 @@ extension ModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat guard case .trafficManagement(let v)? = self.payloadVariant else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 15) }() + case .tak?: try { + guard case .tak(let v)? = self.payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 16) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -2728,6 +2780,41 @@ extension ModuleConfig.StatusMessageConfig: SwiftProtobuf.Message, SwiftProtobuf } } +extension ModuleConfig.TAKConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = ModuleConfig.protoMessageName + ".TAKConfig" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}team\0\u{1}role\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.team) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self.role) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.team != .unspecifedColor { + try visitor.visitSingularEnumField(value: self.team, fieldNumber: 1) + } + if self.role != .unspecifed { + try visitor.visitSingularEnumField(value: self.role, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: ModuleConfig.TAKConfig, rhs: ModuleConfig.TAKConfig) -> Bool { + if lhs.team != rhs.team {return false} + if lhs.role != rhs.role {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension RemoteHardwarePin: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".RemoteHardwarePin" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}gpio_pin\0\u{1}name\0\u{1}type\0") diff --git a/MeshtasticProtobufs/Sources/meshtastic/portnums.pb.swift b/MeshtasticProtobufs/Sources/meshtastic/portnums.pb.swift index 7022a761..6c2089bf 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/portnums.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/portnums.pb.swift @@ -211,6 +211,11 @@ public enum PortNum: SwiftProtobuf.Enum, Swift.CaseIterable { /// PowerStress based monitoring support (for automated power consumption testing) case powerstressApp // = 74 + /// + /// LoraWAN Payload Transport + /// ENCODING: compact binary LoRaWAN uplink (10-byte RF metadata + PHY payload) - see LoRaWANBridgeModule + case lorawanBridge // = 75 + /// /// Reticulum Network Stack Tunnel App /// ENCODING: Fragmented RNS Packet. Handled by Meshtastic RNS interface @@ -222,6 +227,12 @@ public enum PortNum: SwiftProtobuf.Enum, Swift.CaseIterable { /// ENCODING: CayenneLLP case cayenneApp // = 77 + /// + /// GroupAlarm integration + /// Used for transporting GroupAlarm-related messages between Meshtastic nodes + /// and companion applications/services. + case groupalarmApp // = 112 + /// /// Private applications should use portnums >= 256. /// To simplify initial development and testing you can use "PRIVATE_APP" @@ -273,8 +284,10 @@ public enum PortNum: SwiftProtobuf.Enum, Swift.CaseIterable { case 72: self = .atakPlugin case 73: self = .mapReportApp case 74: self = .powerstressApp + case 75: self = .lorawanBridge case 76: self = .reticulumTunnelApp case 77: self = .cayenneApp + case 112: self = .groupalarmApp case 256: self = .privateApp case 257: self = .atakForwarder case 511: self = .max @@ -313,8 +326,10 @@ public enum PortNum: SwiftProtobuf.Enum, Swift.CaseIterable { case .atakPlugin: return 72 case .mapReportApp: return 73 case .powerstressApp: return 74 + case .lorawanBridge: return 75 case .reticulumTunnelApp: return 76 case .cayenneApp: return 77 + case .groupalarmApp: return 112 case .privateApp: return 256 case .atakForwarder: return 257 case .max: return 511 @@ -353,8 +368,10 @@ public enum PortNum: SwiftProtobuf.Enum, Swift.CaseIterable { .atakPlugin, .mapReportApp, .powerstressApp, + .lorawanBridge, .reticulumTunnelApp, .cayenneApp, + .groupalarmApp, .privateApp, .atakForwarder, .max, @@ -365,5 +382,5 @@ public enum PortNum: SwiftProtobuf.Enum, Swift.CaseIterable { // MARK: - Code below here is support for the SwiftProtobuf runtime. extension PortNum: SwiftProtobuf._ProtoNameProviding { - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN_APP\0\u{1}TEXT_MESSAGE_APP\0\u{1}REMOTE_HARDWARE_APP\0\u{1}POSITION_APP\0\u{1}NODEINFO_APP\0\u{1}ROUTING_APP\0\u{1}ADMIN_APP\0\u{1}TEXT_MESSAGE_COMPRESSED_APP\0\u{1}WAYPOINT_APP\0\u{1}AUDIO_APP\0\u{1}DETECTION_SENSOR_APP\0\u{1}ALERT_APP\0\u{1}KEY_VERIFICATION_APP\0\u{2}\u{14}REPLY_APP\0\u{1}IP_TUNNEL_APP\0\u{1}PAXCOUNTER_APP\0\u{1}STORE_FORWARD_PLUSPLUS_APP\0\u{1}NODE_STATUS_APP\0\u{2}\u{1c}SERIAL_APP\0\u{1}STORE_FORWARD_APP\0\u{1}RANGE_TEST_APP\0\u{1}TELEMETRY_APP\0\u{1}ZPS_APP\0\u{1}SIMULATOR_APP\0\u{1}TRACEROUTE_APP\0\u{1}NEIGHBORINFO_APP\0\u{1}ATAK_PLUGIN\0\u{1}MAP_REPORT_APP\0\u{1}POWERSTRESS_APP\0\u{2}\u{2}RETICULUM_TUNNEL_APP\0\u{1}CAYENNE_APP\0\u{2}s\u{2}PRIVATE_APP\0\u{1}ATAK_FORWARDER\0\u{2}~\u{3}MAX\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN_APP\0\u{1}TEXT_MESSAGE_APP\0\u{1}REMOTE_HARDWARE_APP\0\u{1}POSITION_APP\0\u{1}NODEINFO_APP\0\u{1}ROUTING_APP\0\u{1}ADMIN_APP\0\u{1}TEXT_MESSAGE_COMPRESSED_APP\0\u{1}WAYPOINT_APP\0\u{1}AUDIO_APP\0\u{1}DETECTION_SENSOR_APP\0\u{1}ALERT_APP\0\u{1}KEY_VERIFICATION_APP\0\u{2}\u{14}REPLY_APP\0\u{1}IP_TUNNEL_APP\0\u{1}PAXCOUNTER_APP\0\u{1}STORE_FORWARD_PLUSPLUS_APP\0\u{1}NODE_STATUS_APP\0\u{2}\u{1c}SERIAL_APP\0\u{1}STORE_FORWARD_APP\0\u{1}RANGE_TEST_APP\0\u{1}TELEMETRY_APP\0\u{1}ZPS_APP\0\u{1}SIMULATOR_APP\0\u{1}TRACEROUTE_APP\0\u{1}NEIGHBORINFO_APP\0\u{1}ATAK_PLUGIN\0\u{1}MAP_REPORT_APP\0\u{1}POWERSTRESS_APP\0\u{1}LORAWAN_BRIDGE\0\u{1}RETICULUM_TUNNEL_APP\0\u{1}CAYENNE_APP\0\u{2}#GROUPALARM_APP\0\u{2}P\u{2}PRIVATE_APP\0\u{1}ATAK_FORWARDER\0\u{2}~\u{3}MAX\0") } diff --git a/MeshtasticProtobufs/Sources/meshtastic/telemetry.pb.swift b/MeshtasticProtobufs/Sources/meshtastic/telemetry.pb.swift index 0c66c6bc..1b69c47a 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/telemetry.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/telemetry.pb.swift @@ -54,7 +54,7 @@ public enum TelemetrySensorType: SwiftProtobuf.Enum, Swift.CaseIterable { case bmp280 // = 6 /// - /// High accuracy temperature and humidity + /// TODO - REMOVE High accuracy temperature and humidity case shtc3 // = 7 /// @@ -74,7 +74,7 @@ public enum TelemetrySensorType: SwiftProtobuf.Enum, Swift.CaseIterable { case qmc5883L // = 11 /// - /// High accuracy temperature and humidity + /// TODO - REMOVE High accuracy temperature and humidity case sht31 // = 12 /// @@ -94,7 +94,7 @@ public enum TelemetrySensorType: SwiftProtobuf.Enum, Swift.CaseIterable { case rcwl9620 // = 16 /// - /// Sensirion High accuracy temperature and humidity + /// TODO - REMOVE Sensirion High accuracy temperature and humidity case sht4X // = 17 /// @@ -214,12 +214,20 @@ public enum TelemetrySensorType: SwiftProtobuf.Enum, Swift.CaseIterable { case hdc1080 // = 46 /// - /// STH21 Temperature and R. Humidity sensor + /// TODO - REMOVE STH21 Temperature and R. Humidity sensor case sht21 // = 47 /// /// Sensirion STC31 CO2 sensor case stc31 // = 48 + + /// + /// SCD30 CO2, humidity, temperature sensor + case scd30 // = 49 + + /// + /// SHT family of sensors for temperature and humidity + case shtxx // = 50 case UNRECOGNIZED(Int) public init() { @@ -277,6 +285,8 @@ public enum TelemetrySensorType: SwiftProtobuf.Enum, Swift.CaseIterable { case 46: self = .hdc1080 case 47: self = .sht21 case 48: self = .stc31 + case 49: self = .scd30 + case 50: self = .shtxx default: self = .UNRECOGNIZED(rawValue) } } @@ -332,6 +342,8 @@ public enum TelemetrySensorType: SwiftProtobuf.Enum, Swift.CaseIterable { case .hdc1080: return 46 case .sht21: return 47 case .stc31: return 48 + case .scd30: return 49 + case .shtxx: return 50 case .UNRECOGNIZED(let i): return i } } @@ -387,6 +399,8 @@ public enum TelemetrySensorType: SwiftProtobuf.Enum, Swift.CaseIterable { .hdc1080, .sht21, .stc31, + .scd30, + .shtxx, ] } @@ -1673,7 +1687,7 @@ public struct SEN5XState: Sendable { fileprivate let _protobuf_package = "meshtastic" extension TelemetrySensorType: SwiftProtobuf._ProtoNameProviding { - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0SENSOR_UNSET\0\u{1}BME280\0\u{1}BME680\0\u{1}MCP9808\0\u{1}INA260\0\u{1}INA219\0\u{1}BMP280\0\u{1}SHTC3\0\u{1}LPS22\0\u{1}QMC6310\0\u{1}QMI8658\0\u{1}QMC5883L\0\u{1}SHT31\0\u{1}PMSA003I\0\u{1}INA3221\0\u{1}BMP085\0\u{1}RCWL9620\0\u{1}SHT4X\0\u{1}VEML7700\0\u{1}MLX90632\0\u{1}OPT3001\0\u{1}LTR390UV\0\u{1}TSL25911FN\0\u{1}AHT10\0\u{1}DFROBOT_LARK\0\u{1}NAU7802\0\u{1}BMP3XX\0\u{1}ICM20948\0\u{1}MAX17048\0\u{1}CUSTOM_SENSOR\0\u{1}MAX30102\0\u{1}MLX90614\0\u{1}SCD4X\0\u{1}RADSENS\0\u{1}INA226\0\u{1}DFROBOT_RAIN\0\u{1}DPS310\0\u{1}RAK12035\0\u{1}MAX17261\0\u{1}PCT2075\0\u{1}ADS1X15\0\u{1}ADS1X15_ALT\0\u{1}SFA30\0\u{1}SEN5X\0\u{1}TSL2561\0\u{1}BH1750\0\u{1}HDC1080\0\u{1}SHT21\0\u{1}STC31\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0SENSOR_UNSET\0\u{1}BME280\0\u{1}BME680\0\u{1}MCP9808\0\u{1}INA260\0\u{1}INA219\0\u{1}BMP280\0\u{1}SHTC3\0\u{1}LPS22\0\u{1}QMC6310\0\u{1}QMI8658\0\u{1}QMC5883L\0\u{1}SHT31\0\u{1}PMSA003I\0\u{1}INA3221\0\u{1}BMP085\0\u{1}RCWL9620\0\u{1}SHT4X\0\u{1}VEML7700\0\u{1}MLX90632\0\u{1}OPT3001\0\u{1}LTR390UV\0\u{1}TSL25911FN\0\u{1}AHT10\0\u{1}DFROBOT_LARK\0\u{1}NAU7802\0\u{1}BMP3XX\0\u{1}ICM20948\0\u{1}MAX17048\0\u{1}CUSTOM_SENSOR\0\u{1}MAX30102\0\u{1}MLX90614\0\u{1}SCD4X\0\u{1}RADSENS\0\u{1}INA226\0\u{1}DFROBOT_RAIN\0\u{1}DPS310\0\u{1}RAK12035\0\u{1}MAX17261\0\u{1}PCT2075\0\u{1}ADS1X15\0\u{1}ADS1X15_ALT\0\u{1}SFA30\0\u{1}SEN5X\0\u{1}TSL2561\0\u{1}BH1750\0\u{1}HDC1080\0\u{1}SHT21\0\u{1}STC31\0\u{1}SCD30\0\u{1}SHTXX\0") } extension DeviceMetrics: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { diff --git a/protobufs b/protobufs index c8d5047b..349c1d5c 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit c8d5047b6351b732c0bccfcea6960a532f7ae49a +Subproject commit 349c1d5c1e3ab716a65d7dab1597923b4542796d From eacd62b23260d523b607bcdc29b1e1e0a9511c0e Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 7 Apr 2026 06:33:46 -0700 Subject: [PATCH 20/34] Update NFC Entitlement --- Meshtastic/Meshtastic.entitlements | 1 + protobufs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index bc694209..4f6e952d 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -12,6 +12,7 @@ com.apple.developer.nfc.readersession.formats TAG + NDEF com.apple.developer.usernotifications.critical-alerts diff --git a/protobufs b/protobufs index 349c1d5c..27fac391 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 349c1d5c1e3ab716a65d7dab1597923b4542796d +Subproject commit 27fac39141d99fe727a0a1824c5397409b1aea75 From dffd54004540c4e90b6e6624935395a0d43d8693 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 7 Apr 2026 08:58:24 -0500 Subject: [PATCH 21/34] Add TAK Config settings screen --- Localizable.xcstrings | 20 + Meshtastic.xcodeproj/project.pbxproj | 8 +- .../AccessoryManager+ToRadio.swift | 56 ++ Meshtastic/Helpers/MeshPackets.swift | 4 + .../Meshtastic.xcdatamodeld/.xccurrentversion | 2 +- .../contents | 519 ++++++++++++++++++ Meshtastic/Persistence/UpdateCoreData.swift | 47 ++ Meshtastic/Router/NavigationState.swift | 1 + Meshtastic/Views/Settings/Settings.swift | 16 +- .../Views/Settings/TAKServerConfig.swift | 234 ++++++++ 10 files changed, 895 insertions(+), 12 deletions(-) create mode 100644 Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 57.xcdatamodel/contents diff --git a/Localizable.xcstrings b/Localizable.xcstrings index a21426e4..dde69cfe 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -26820,6 +26820,10 @@ } } }, + "Identity" : { + "comment" : "A section", + "isCommentAutoGenerated" : true + }, "If DOP is set, use HDOP / VDOP values instead of PDOP" : { "localizations" : { "da" : { @@ -52839,6 +52843,10 @@ "comment" : "A label for the TAK channel index.", "isCommentAutoGenerated" : true }, + "TAK Config" : { + "comment" : "The title of the TAK module configuration screen.", + "isCommentAutoGenerated" : true + }, "TAK Server" : { }, @@ -53079,6 +53087,10 @@ } } }, + "Team" : { + "comment" : "A label for the team picker.", + "isCommentAutoGenerated" : true + }, "Telemetry" : { "localizations" : { "da" : { @@ -54794,6 +54806,9 @@ } } } + }, + "These settings only apply when the device role is TAK or TAK Tracker." : { + }, "These settings will %@" : { "comment" : "A paragraph below the title that explains what the user is about to do.", @@ -54854,6 +54869,10 @@ } } }, + "These values are included in TAK position reports. Leave either setting at Default to let firmware use Cyan and Team Member." : { + "comment" : "A", + "isCommentAutoGenerated" : true + }, "Thirty Minutes" : { "localizations" : { "da" : { @@ -55299,6 +55318,7 @@ } }, "This node does not support any configurable modules." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 4a83769b..5ff2936e 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -101,7 +101,6 @@ 8E587743574CE17703E892C6 /* Certificates in Resources */ = {isa = PBXBuildFile; fileRef = 518D504DED9874EBF9D76578 /* Certificates */; }; 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; }; 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; }; - DCC919C6B47C15BB0795456C /* Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D5AD8037A0D583C614B0597 /* Tools.swift */; }; A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; }; AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; }; @@ -135,6 +134,7 @@ D9C9839D2B79CFD700BDBE6A /* TextMessageSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C9839C2B79CFD700BDBE6A /* TextMessageSize.swift */; }; D9C983A02B79D0E800BDBE6A /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C9839F2B79D0E800BDBE6A /* AlertButton.swift */; }; D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C983A12B79D1A600BDBE6A /* RequestPositionButton.swift */; }; + DCC919C6B47C15BB0795456C /* Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D5AD8037A0D583C614B0597 /* Tools.swift */; }; DD007BAE2AA4E91200F5FA12 /* MyInfoEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */; }; DD007BB02AA5981000F5FA12 /* NodeInfoEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD007BAF2AA5981000F5FA12 /* NodeInfoEntityExtension.swift */; }; DD09240001E7FAD600E70001 /* MapLegend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD09240002E7FAD600E70001 /* MapLegend.swift */; }; @@ -349,11 +349,11 @@ /* Begin PBXFileReference section */ 01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = ""; }; - 1D5AD8037A0D583C614B0597 /* Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tools.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 = ""; }; 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = ""; }; 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = ""; }; + 1D5AD8037A0D583C614B0597 /* Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tools.swift; sourceTree = ""; }; 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = ""; }; 231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEAuthorizationHelper.swift; sourceTree = ""; }; 231A53772E69ADB900216B99 /* NodeFilterParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeFilterParameters.swift; sourceTree = ""; }; @@ -437,6 +437,7 @@ 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetricsLog.swift; sourceTree = ""; }; 9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKDataPackageGenerator.swift; sourceTree = ""; }; AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = ""; }; + AA0001022F07A4B000600001 /* MeshtasticDataModelV 57.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 57.xcdatamodel"; sourceTree = ""; }; ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconButton.swift; sourceTree = ""; }; ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPicker.swift; sourceTree = ""; }; B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = ""; }; @@ -2439,6 +2440,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + AA0001022F07A4B000600001 /* MeshtasticDataModelV 57.xcdatamodel */, DD7D46262F833B520028AC1A /* MeshtasticDataModelV 56.xcdatamodel */, DD04804A2E9295A5005F946C /* MeshtasticDataModelV 55.xcdatamodel */, DDDF34392E2CB8E600356DC3 /* MeshtasticDataModelV 54.xcdatamodel */, @@ -2496,7 +2498,7 @@ DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DD7D46262F833B520028AC1A /* MeshtasticDataModelV 56.xcdatamodel */; + currentVersion = AA0001022F07A4B000600001 /* MeshtasticDataModelV 57.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index f34b5ae4..4fa73d2d 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -1820,6 +1820,32 @@ extension AccessoryManager { try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) } + public func requestTAKModuleConfig(fromUser: UserEntity, toUser: UserEntity) async throws { + + var adminPacket = AdminMessage() + adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.takConfig + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.tak = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. _XCCurrentVersionName - MeshtasticDataModelV 56.xcdatamodel + MeshtasticDataModelV 57.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 57.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 57.xcdatamodel/contents new file mode 100644 index 00000000..cd39db2a --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 57.xcdatamodel/contents @@ -0,0 +1,519 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index 32d9bc99..5a141ed3 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -1791,4 +1791,51 @@ extension MeshPackets { Logger.data.error("πŸ’₯ [TelemetryConfigEntity] Fetching node for core data TelemetryConfigEntity failed: \(nsError, privacy: .public)") } } + + func upsertTAKModuleConfigPacket(config: ModuleConfig.TAKConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertTAKModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertTAKModuleConfigPacket(config: ModuleConfig.TAKConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("TAK module config received: %@".localized, String(nodeNum)) + Logger.data.info("🎯 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + if !fetchedNode.isEmpty { + if fetchedNode[0].takConfig == nil { + let newTAKConfig = TAKConfigEntity(context: context) + newTAKConfig.team = Int32(config.team.rawValue) + newTAKConfig.role = Int32(config.role.rawValue) + fetchedNode[0].takConfig = newTAKConfig + } else { + fetchedNode[0].takConfig?.team = Int32(config.team.rawValue) + fetchedNode[0].takConfig?.role = Int32(config.role.rawValue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("πŸ’Ύ [TAKConfigEntity] Updated TAK Module Config for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("πŸ’₯ [TAKConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("πŸ’₯ [TAKConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save TAK Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("πŸ’₯ [TAKConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } } diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index 173a2c4e..c087f478 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -53,6 +53,7 @@ enum SettingsNavigationState: String { case appFiles case firmwareUpdates case tak + case takConfig case tools } diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index d4fe2712..26a2d247 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -276,14 +276,12 @@ struct Settings: View { } } - // Update this list with the modules that are shown above. If all are not supported - // Then show a message. - if !isAnySupported([.ambientlightingConfig, .cannedmsgConfig, - .detectionsensorConfig, .extnotifConfig, - .mqttConfig, .rangetestConfig, .paxcounterConfig, - .audioConfig, .serialConfig, .storeforwardConfig, - .telemetryConfig]) { - Text("This node does not support any configurable modules.") + NavigationLink(value: SettingsNavigationState.takConfig) { + Label { + Text("TAK") + } icon: { + Image(systemName: "shield.checkered") + } } } header: { Text("Module Configuration") @@ -545,6 +543,8 @@ struct Settings: View { Tools() case .tak: TAKServerConfig() + case .takConfig: + TAKModuleConfig(node: nodes.first(where: { $0.num == selectedNode })) } } .onChange(of: UserDefaults.preferredPeripheralNum ) { _, newConnectedNode in diff --git a/Meshtastic/Views/Settings/TAKServerConfig.swift b/Meshtastic/Views/Settings/TAKServerConfig.swift index 7e8b6502..3f615f04 100644 --- a/Meshtastic/Views/Settings/TAKServerConfig.swift +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -9,6 +9,7 @@ import SwiftUI import UniformTypeIdentifiers import OSLog import CoreData +import MeshtasticProtobufs enum CertificateImportType { case p12 @@ -563,3 +564,236 @@ struct ZipDocument: FileDocument { FileWrapper(regularFileWithContents: data) } } + +struct TAKModuleConfig: View { + @Environment(\.managedObjectContext) private var context + @EnvironmentObject private var accessoryManager: AccessoryManager + @Environment(\.dismiss) private var goBack + + let node: NodeInfoEntity? + + @State private var hasChanges = false + @State private var team = Team.unspecifedColor.rawValue + @State private var role = MemberRole.unspecifed.rawValue + + private var selectedTeam: Team { + Team(rawValue: team) ?? .unspecifedColor + } + + private var selectedRole: MemberRole { + MemberRole(rawValue: role) ?? .unspecifed + } + + private var deviceRole: DeviceRoles? { + guard let role = node?.deviceConfig?.role else { return nil } + return DeviceRoles(rawValue: Int(role)) + } + + var body: some View { + Form { + ConfigHeader(title: "TAK", config: \.takConfig, node: node, onAppear: setTAKValues) + + if let deviceRole, deviceRole != .tak && deviceRole != .takTracker { + Section { + Text("These settings only apply when the device role is TAK or TAK Tracker.") + .font(.callout) + .foregroundColor(.orange) + } + } + + Section(header: Text("Identity")) { + VStack(alignment: .leading) { + Picker("Team", selection: $team) { + ForEach(Team.allCases, id: \.rawValue) { teamOption in + Text(teamTitle(teamOption)).tag(teamOption.rawValue) + } + } + .pickerStyle(DefaultPickerStyle()) + Text(teamHelpText(selectedTeam)) + .foregroundColor(.gray) + .font(.callout) + } + + VStack(alignment: .leading) { + Picker("Role", selection: $role) { + ForEach(MemberRole.allCases, id: \.rawValue) { roleOption in + Text(roleTitle(roleOption)).tag(roleOption.rawValue) + } + } + .pickerStyle(DefaultPickerStyle()) + Text(roleHelpText(selectedRole)) + .foregroundColor(.gray) + .font(.callout) + } + } + + Section { + Text("These values are included in TAK position reports. Leave either setting at Default to let firmware use Cyan and Team Member.") + .foregroundColor(.gray) + .font(.callout) + } + } + .disabled(!accessoryManager.isConnected || node?.takConfig == nil) + .safeAreaInset(edge: .bottom, alignment: .center) { + HStack(spacing: 0) { + SaveConfigButton(node: node, hasChanges: $hasChanges) { + guard let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context), + let fromUser = connectedNode.user, + let toUser = node?.user else { + return + } + + var config = ModuleConfig.TAKConfig() + config.team = selectedTeam + config.role = selectedRole + + Task { + _ = try await accessoryManager.saveTAKModuleConfig(config: config, fromUser: fromUser, toUser: toUser) + Task { @MainActor in + hasChanges = false + goBack() + } + } + } + } + } + .navigationTitle("TAK Config") + .navigationBarItems( + trailing: ZStack { + ConnectedDevice( + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?" + ) + } + ) + .onFirstAppear { + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) + if let connectedNode, node.num != deviceNum { + if UserDefaults.enableAdministration { + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.takConfig == nil { + Task { + do { + Logger.mesh.info("βš™οΈ Empty or expired TAK module config requesting via PKI admin") + try await accessoryManager.requestTAKModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 TAK module config request failed: \(error.localizedDescription)") + } + } + } + } else { + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") + } + } + } + } + .onChange(of: team) { _, newTeam in + if newTeam != Int(node?.takConfig?.team ?? Int32(Team.unspecifedColor.rawValue)) { + hasChanges = true + } + } + .onChange(of: role) { _, newRole in + if newRole != Int(node?.takConfig?.role ?? Int32(MemberRole.unspecifed.rawValue)) { + hasChanges = true + } + } + } + + private func setTAKValues() { + team = Int(node?.takConfig?.team ?? Int32(Team.unspecifedColor.rawValue)) + role = Int(node?.takConfig?.role ?? Int32(MemberRole.unspecifed.rawValue)) + hasChanges = false + } + + private func teamTitle(_ team: Team) -> String { + switch team { + case .unspecifedColor: + return "Default (Cyan)" + case .white: + return "White" + case .yellow: + return "Yellow" + case .orange: + return "Orange" + case .magenta: + return "Magenta" + case .red: + return "Red" + case .maroon: + return "Maroon" + case .purple: + return "Purple" + case .darkBlue: + return "Dark Blue" + case .blue: + return "Blue" + case .cyan: + return "Cyan" + case .teal: + return "Teal" + case .green: + return "Green" + case .darkGreen: + return "Dark Green" + case .brown: + return "Brown" + case .UNRECOGNIZED: + return "Unknown" + } + } + + private func roleTitle(_ role: MemberRole) -> String { + switch role { + case .unspecifed: + return "Default (Team Member)" + case .teamMember: + return "Team Member" + case .teamLead: + return "Team Lead" + case .hq: + return "HQ" + case .sniper: + return "Sniper" + case .medic: + return "Medic" + case .forwardObserver: + return "Forward Observer" + case .rto: + return "RTO" + case .k9: + return "K9" + case .UNRECOGNIZED: + return "Unknown" + } + } + + private func teamHelpText(_ team: Team) -> String { + switch team { + case .unspecifedColor: + return "Default uses Cyan." + case .UNRECOGNIZED: + return "Unknown team color." + default: + return "Shown to TAK clients as the \(teamTitle(team)) team color." + } + } + + private func roleHelpText(_ role: MemberRole) -> String { + switch role { + case .unspecifed: + return "Default uses Team Member." + case .UNRECOGNIZED: + return "Unknown TAK role." + default: + return "Shown to TAK clients as the \(roleTitle(role)) role." + } + } +} + +#Preview { + let context = PersistenceController.preview.container.viewContext + return TAKModuleConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} From 0dd29bee97418aafe43ec688c7b6441438523246 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 7 Apr 2026 08:33:40 -0700 Subject: [PATCH 22/34] Remove NDEF --- Meshtastic/Meshtastic.entitlements | 1 - 1 file changed, 1 deletion(-) diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index 4f6e952d..bc694209 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -12,7 +12,6 @@ com.apple.developer.nfc.readersession.formats TAG - NDEF com.apple.developer.usernotifications.critical-alerts From 7964a6bab324269a7c57841337d3e61c509b5df9 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 7 Apr 2026 13:51:06 -0500 Subject: [PATCH 23/34] Moved some things around --- Localizable.xcstrings | 1 - Meshtastic.xcodeproj/project.pbxproj | 4 + .../AccessoryManager+ToRadio.swift | 4 +- .../Config/Module/TAKModuleConfig.swift | 264 ++++++++++++++++++ Meshtastic/Views/Settings/Settings.swift | 53 +++- .../Views/Settings/TAKServerConfig.swift | 234 ---------------- 6 files changed, 318 insertions(+), 242 deletions(-) create mode 100644 Meshtastic/Views/Settings/Config/Module/TAKModuleConfig.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index dde69cfe..27047cb7 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -55318,7 +55318,6 @@ } }, "This node does not support any configurable modules." : { - "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 5ff2936e..815a8db1 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -102,6 +102,7 @@ 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; }; 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; }; A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; }; + AA0001032F07A4B000600001 /* TAKModuleConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001042F07A4B000600001 /* TAKModuleConfig.swift */; }; AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; }; ABB99DEB2E2EA1C500CFBD05 /* AppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */; }; @@ -351,6 +352,7 @@ 01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.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 = ""; }; + AA0001042F07A4B000600001 /* TAKModuleConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TAKModuleConfig.swift; sourceTree = ""; }; 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = ""; }; 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = ""; }; 1D5AD8037A0D583C614B0597 /* Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tools.swift; sourceTree = ""; }; @@ -1085,6 +1087,7 @@ DD15E4F22B8BA56E00654F61 /* PaxCounterConfig.swift */, DD6193782863875F00E59241 /* SerialConfig.swift */, DDF6B2472A9AEBF500BA6931 /* StoreForwardConfig.swift */, + AA0001042F07A4B000600001 /* TAKModuleConfig.swift */, DD415827285859C4009B0E59 /* TelemetryConfig.swift */, ); path = Module; @@ -1946,6 +1949,7 @@ E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */, FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */, A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */, + AA0001032F07A4B000600001 /* TAKModuleConfig.swift in Sources */, DCC919C6B47C15BB0795456C /* Tools.swift in Sources */, 8398407DBA32EE7CFC16A385 /* TAKDataPackageGenerator.swift in Sources */, 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */, diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index 4fa73d2d..29870d16 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -1824,7 +1824,9 @@ extension AccessoryManager { var adminPacket = AdminMessage() adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.takConfig - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } var meshPacket: MeshPacket = MeshPacket() meshPacket.to = UInt32(toUser.num) meshPacket.from = UInt32(fromUser.num) diff --git a/Meshtastic/Views/Settings/Config/Module/TAKModuleConfig.swift b/Meshtastic/Views/Settings/Config/Module/TAKModuleConfig.swift new file mode 100644 index 00000000..56803bb8 --- /dev/null +++ b/Meshtastic/Views/Settings/Config/Module/TAKModuleConfig.swift @@ -0,0 +1,264 @@ +// +// TAKModuleConfig.swift +// Meshtastic +import SwiftUI +import CoreData +import OSLog +import MeshtasticProtobufs + +struct TAKModuleConfig: View { + @Environment(\.managedObjectContext) private var context + @EnvironmentObject private var accessoryManager: AccessoryManager + @Environment(\.dismiss) private var goBack + + let node: NodeInfoEntity? + + @State private var hasChanges = false + @State private var team = Team.unspecifedColor.rawValue + @State private var role = MemberRole.unspecifed.rawValue + + private var selectedTeam: Team { + Team(rawValue: team) ?? .unspecifedColor + } + + private var selectedRole: MemberRole { + MemberRole(rawValue: role) ?? .unspecifed + } + + private var deviceRole: DeviceRoles? { + guard let role = node?.deviceConfig?.role ?? node?.user?.role else { return nil } + return DeviceRoles(rawValue: Int(role)) + } + + private var isConnectedNode: Bool { + guard let node else { return false } + return node.num == accessoryManager.activeDeviceNum + } + + var body: some View { + Form { + ConfigHeader(title: "TAK", config: \.takConfig, node: node, onAppear: setTAKValues) + + if let deviceRole, deviceRole != .tak && deviceRole != .takTracker { + Section { + Text("These settings only apply when the device role is TAK or TAK Tracker.") + .font(.callout) + .foregroundColor(.orange) + } + } + + Section(header: Text("Identity")) { + VStack(alignment: .leading) { + Picker("Team", selection: $team) { + ForEach(Team.allCases, id: \.rawValue) { teamOption in + Text(teamTitle(teamOption)).tag(teamOption.rawValue) + } + } + .pickerStyle(DefaultPickerStyle()) + Text(teamHelpText(selectedTeam)) + .foregroundColor(.gray) + .font(.callout) + } + + VStack(alignment: .leading) { + Picker("Role", selection: $role) { + ForEach(MemberRole.allCases, id: \.rawValue) { roleOption in + Text(roleTitle(roleOption)).tag(roleOption.rawValue) + } + } + .pickerStyle(DefaultPickerStyle()) + Text(roleHelpText(selectedRole)) + .foregroundColor(.gray) + .font(.callout) + } + } + + Section { + Text("These values are included in TAK position reports. Leave either setting at Default to let firmware use Cyan and Team Member.") + .foregroundColor(.gray) + .font(.callout) + } + } + .disabled(!accessoryManager.isConnected || (!isConnectedNode && node?.takConfig == nil)) + .safeAreaInset(edge: .bottom, alignment: .center) { + HStack(spacing: 0) { + SaveConfigButton(node: node, hasChanges: $hasChanges) { + guard let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context), + let fromUser = connectedNode.user, + let toUser = node?.user else { + return + } + + var config = ModuleConfig.TAKConfig() + config.team = selectedTeam + config.role = selectedRole + + Task { + _ = try await accessoryManager.saveTAKModuleConfig(config: config, fromUser: fromUser, toUser: toUser) + Task { @MainActor in + hasChanges = false + goBack() + } + } + } + } + } + .navigationTitle("TAK Config") + .navigationBarItems( + trailing: ZStack { + ConnectedDevice( + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?" + ) + } + ) + .onAppear { + // Need to request a TAKModuleConfig from the connected node before allowing changes. + if let deviceNum = accessoryManager.activeDeviceNum, + let node, + node.num == deviceNum, + node.takConfig == nil { + let connectedNode = getNodeInfo(id: deviceNum, context: context) + if let connectedNode { + Task { + do { + Logger.mesh.info("βš™οΈ Empty TAK module config requesting from connected node") + try await accessoryManager.requestTAKModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.error("🚨 TAK module config request failed: \(error.localizedDescription)") + } + } + } + } + } + .onFirstAppear { + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) + if let connectedNode, node.num != deviceNum { + if UserDefaults.enableAdministration { + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.takConfig == nil { + Task { + do { + Logger.mesh.info("βš™οΈ Empty or expired TAK module config requesting via PKI admin") + try await accessoryManager.requestTAKModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 TAK module config request failed: \(error.localizedDescription)") + } + } + } + } else { + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") + } + } + } + } + .onChange(of: team) { _, newTeam in + if newTeam != Int(node?.takConfig?.team ?? Int32(Team.unspecifedColor.rawValue)) { + hasChanges = true + } + } + .onChange(of: role) { _, newRole in + if newRole != Int(node?.takConfig?.role ?? Int32(MemberRole.unspecifed.rawValue)) { + hasChanges = true + } + } + } + + private func setTAKValues() { + team = Int(node?.takConfig?.team ?? Int32(Team.unspecifedColor.rawValue)) + role = Int(node?.takConfig?.role ?? Int32(MemberRole.unspecifed.rawValue)) + hasChanges = false + } + + private func teamTitle(_ team: Team) -> String { + switch team { + case .unspecifedColor: + return "Default (Cyan)" + case .white: + return "White" + case .yellow: + return "Yellow" + case .orange: + return "Orange" + case .magenta: + return "Magenta" + case .red: + return "Red" + case .maroon: + return "Maroon" + case .purple: + return "Purple" + case .darkBlue: + return "Dark Blue" + case .blue: + return "Blue" + case .cyan: + return "Cyan" + case .teal: + return "Teal" + case .green: + return "Green" + case .darkGreen: + return "Dark Green" + case .brown: + return "Brown" + case .UNRECOGNIZED: + return "Unknown" + } + } + + private func roleTitle(_ role: MemberRole) -> String { + switch role { + case .unspecifed: + return "Default (Team Member)" + case .teamMember: + return "Team Member" + case .teamLead: + return "Team Lead" + case .hq: + return "HQ" + case .sniper: + return "Sniper" + case .medic: + return "Medic" + case .forwardObserver: + return "Forward Observer" + case .rto: + return "RTO" + case .k9: + return "K9" + case .UNRECOGNIZED: + return "Unknown" + } + } + + private func teamHelpText(_ team: Team) -> String { + switch team { + case .unspecifedColor: + return "Default uses Cyan." + case .UNRECOGNIZED: + return "Unknown team color." + default: + return "Shown to TAK clients as the \(teamTitle(team)) team color." + } + } + + private func roleHelpText(_ role: MemberRole) -> String { + switch role { + case .unspecifed: + return "Default uses Team Member." + case .UNRECOGNIZED: + return "Unknown TAK role." + default: + return "Shown to TAK clients as the \(roleTitle(role)) role." + } + } +} + +#Preview { + let context = PersistenceController.preview.container.viewContext + return TAKModuleConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 26a2d247..363842a2 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -33,14 +33,48 @@ struct Settings: View { // MARK: Helper + private var moduleConfigurationNode: NodeInfoEntity? { + let nodeNum = selectedNode > 0 ? selectedNode : preferredNodeNum + return nodes.first(where: { $0.num == nodeNum }) + } + + private var showsAnyModuleConfiguration: Bool { + isAnySupported([ + .ambientlightingConfig, + .cannedmsgConfig, + .detectionsensorConfig, + .extnotifConfig, + .mqttConfig, + .rangetestConfig, + .paxcounterConfig, + .serialConfig, + .storeforwardConfig, + .telemetryConfig + ]) || isTAKModuleSupported() + } + private func isModuleSupported(_ module: ExcludedModules) -> Bool { - return Int(nodes.first(where: { $0.num == preferredNodeNum })?.metadata?.excludedModules ?? Int32.zero) & module.rawValue == 0 + return Int(moduleConfigurationNode?.metadata?.excludedModules ?? Int32.zero) & module.rawValue == 0 } private func isAnySupported(_ modules: [ExcludedModules]) -> Bool { return modules.map(isModuleSupported).contains(true) } + private func isTAKModuleSupported() -> Bool { + guard let node = moduleConfigurationNode else { return false } + if node.takConfig != nil { + return true + } + + guard let roleValue = node.deviceConfig?.role ?? node.user?.role, + let deviceRole = DeviceRoles(rawValue: Int(roleValue)) else { + return false + } + + return deviceRole == .tak || deviceRole == .takTracker + } + // MARK: Views var radioConfigurationSection: some View { @@ -276,13 +310,20 @@ struct Settings: View { } } - NavigationLink(value: SettingsNavigationState.takConfig) { - Label { - Text("TAK") - } icon: { - Image(systemName: "shield.checkered") + if isTAKModuleSupported() { + NavigationLink(value: SettingsNavigationState.takConfig) { + Label { + Text("TAK") + } icon: { + Image(systemName: "shield.checkered") + } } } + + if !showsAnyModuleConfiguration { + Text("This node does not support any configurable modules.") + .foregroundColor(.secondary) + } } header: { Text("Module Configuration") } diff --git a/Meshtastic/Views/Settings/TAKServerConfig.swift b/Meshtastic/Views/Settings/TAKServerConfig.swift index 3f615f04..7e8b6502 100644 --- a/Meshtastic/Views/Settings/TAKServerConfig.swift +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -9,7 +9,6 @@ import SwiftUI import UniformTypeIdentifiers import OSLog import CoreData -import MeshtasticProtobufs enum CertificateImportType { case p12 @@ -564,236 +563,3 @@ struct ZipDocument: FileDocument { FileWrapper(regularFileWithContents: data) } } - -struct TAKModuleConfig: View { - @Environment(\.managedObjectContext) private var context - @EnvironmentObject private var accessoryManager: AccessoryManager - @Environment(\.dismiss) private var goBack - - let node: NodeInfoEntity? - - @State private var hasChanges = false - @State private var team = Team.unspecifedColor.rawValue - @State private var role = MemberRole.unspecifed.rawValue - - private var selectedTeam: Team { - Team(rawValue: team) ?? .unspecifedColor - } - - private var selectedRole: MemberRole { - MemberRole(rawValue: role) ?? .unspecifed - } - - private var deviceRole: DeviceRoles? { - guard let role = node?.deviceConfig?.role else { return nil } - return DeviceRoles(rawValue: Int(role)) - } - - var body: some View { - Form { - ConfigHeader(title: "TAK", config: \.takConfig, node: node, onAppear: setTAKValues) - - if let deviceRole, deviceRole != .tak && deviceRole != .takTracker { - Section { - Text("These settings only apply when the device role is TAK or TAK Tracker.") - .font(.callout) - .foregroundColor(.orange) - } - } - - Section(header: Text("Identity")) { - VStack(alignment: .leading) { - Picker("Team", selection: $team) { - ForEach(Team.allCases, id: \.rawValue) { teamOption in - Text(teamTitle(teamOption)).tag(teamOption.rawValue) - } - } - .pickerStyle(DefaultPickerStyle()) - Text(teamHelpText(selectedTeam)) - .foregroundColor(.gray) - .font(.callout) - } - - VStack(alignment: .leading) { - Picker("Role", selection: $role) { - ForEach(MemberRole.allCases, id: \.rawValue) { roleOption in - Text(roleTitle(roleOption)).tag(roleOption.rawValue) - } - } - .pickerStyle(DefaultPickerStyle()) - Text(roleHelpText(selectedRole)) - .foregroundColor(.gray) - .font(.callout) - } - } - - Section { - Text("These values are included in TAK position reports. Leave either setting at Default to let firmware use Cyan and Team Member.") - .foregroundColor(.gray) - .font(.callout) - } - } - .disabled(!accessoryManager.isConnected || node?.takConfig == nil) - .safeAreaInset(edge: .bottom, alignment: .center) { - HStack(spacing: 0) { - SaveConfigButton(node: node, hasChanges: $hasChanges) { - guard let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context), - let fromUser = connectedNode.user, - let toUser = node?.user else { - return - } - - var config = ModuleConfig.TAKConfig() - config.team = selectedTeam - config.role = selectedRole - - Task { - _ = try await accessoryManager.saveTAKModuleConfig(config: config, fromUser: fromUser, toUser: toUser) - Task { @MainActor in - hasChanges = false - goBack() - } - } - } - } - } - .navigationTitle("TAK Config") - .navigationBarItems( - trailing: ZStack { - ConnectedDevice( - deviceConnected: accessoryManager.isConnected, - name: accessoryManager.activeConnection?.device.shortName ?? "?" - ) - } - ) - .onFirstAppear { - if let deviceNum = accessoryManager.activeDeviceNum, let node { - let connectedNode = getNodeInfo(id: deviceNum, context: context) - if let connectedNode, node.num != deviceNum { - if UserDefaults.enableAdministration { - let expiration = node.sessionExpiration ?? Date() - if expiration < Date() || node.takConfig == nil { - Task { - do { - Logger.mesh.info("βš™οΈ Empty or expired TAK module config requesting via PKI admin") - try await accessoryManager.requestTAKModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) - } catch { - Logger.mesh.info("🚨 TAK module config request failed: \(error.localizedDescription)") - } - } - } - } else { - Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") - } - } - } - } - .onChange(of: team) { _, newTeam in - if newTeam != Int(node?.takConfig?.team ?? Int32(Team.unspecifedColor.rawValue)) { - hasChanges = true - } - } - .onChange(of: role) { _, newRole in - if newRole != Int(node?.takConfig?.role ?? Int32(MemberRole.unspecifed.rawValue)) { - hasChanges = true - } - } - } - - private func setTAKValues() { - team = Int(node?.takConfig?.team ?? Int32(Team.unspecifedColor.rawValue)) - role = Int(node?.takConfig?.role ?? Int32(MemberRole.unspecifed.rawValue)) - hasChanges = false - } - - private func teamTitle(_ team: Team) -> String { - switch team { - case .unspecifedColor: - return "Default (Cyan)" - case .white: - return "White" - case .yellow: - return "Yellow" - case .orange: - return "Orange" - case .magenta: - return "Magenta" - case .red: - return "Red" - case .maroon: - return "Maroon" - case .purple: - return "Purple" - case .darkBlue: - return "Dark Blue" - case .blue: - return "Blue" - case .cyan: - return "Cyan" - case .teal: - return "Teal" - case .green: - return "Green" - case .darkGreen: - return "Dark Green" - case .brown: - return "Brown" - case .UNRECOGNIZED: - return "Unknown" - } - } - - private func roleTitle(_ role: MemberRole) -> String { - switch role { - case .unspecifed: - return "Default (Team Member)" - case .teamMember: - return "Team Member" - case .teamLead: - return "Team Lead" - case .hq: - return "HQ" - case .sniper: - return "Sniper" - case .medic: - return "Medic" - case .forwardObserver: - return "Forward Observer" - case .rto: - return "RTO" - case .k9: - return "K9" - case .UNRECOGNIZED: - return "Unknown" - } - } - - private func teamHelpText(_ team: Team) -> String { - switch team { - case .unspecifedColor: - return "Default uses Cyan." - case .UNRECOGNIZED: - return "Unknown team color." - default: - return "Shown to TAK clients as the \(teamTitle(team)) team color." - } - } - - private func roleHelpText(_ role: MemberRole) -> String { - switch role { - case .unspecifed: - return "Default uses Team Member." - case .UNRECOGNIZED: - return "Unknown TAK role." - default: - return "Shown to TAK clients as the \(roleTitle(role)) role." - } - } -} - -#Preview { - let context = PersistenceController.preview.container.viewContext - return TAKModuleConfig(node: nil) - .environmentObject(AccessoryManager.shared) - .environment(\.managedObjectContext, context) -} From 85f07608cba8c7486c9e49f2c08942fe21a0fa6d Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 8 Apr 2026 13:26:57 -0500 Subject: [PATCH 24/34] Fix visibility --- Localizable.xcstrings | 3 +++ .../Config/Module/TAKModuleConfig.swift | 17 +++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 27047cb7..34a26370 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -29054,6 +29054,9 @@ } } } + }, + "Loading TAK config from the node." : { + }, "Local Network Access" : { "comment" : "A label displayed above the options for local network access.", diff --git a/Meshtastic/Views/Settings/Config/Module/TAKModuleConfig.swift b/Meshtastic/Views/Settings/Config/Module/TAKModuleConfig.swift index 56803bb8..8125956f 100644 --- a/Meshtastic/Views/Settings/Config/Module/TAKModuleConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/TAKModuleConfig.swift @@ -30,15 +30,20 @@ struct TAKModuleConfig: View { return DeviceRoles(rawValue: Int(role)) } - private var isConnectedNode: Bool { - guard let node else { return false } - return node.num == accessoryManager.activeDeviceNum - } - var body: some View { Form { ConfigHeader(title: "TAK", config: \.takConfig, node: node, onAppear: setTAKValues) + if accessoryManager.isConnected, node?.takConfig == nil { + Section { + HStack(spacing: 12) { + ProgressView() + Text("Loading TAK config from the node.") + .foregroundColor(.secondary) + } + } + } + if let deviceRole, deviceRole != .tak && deviceRole != .takTracker { Section { Text("These settings only apply when the device role is TAK or TAK Tracker.") @@ -79,7 +84,7 @@ struct TAKModuleConfig: View { .font(.callout) } } - .disabled(!accessoryManager.isConnected || (!isConnectedNode && node?.takConfig == nil)) + .disabled(!accessoryManager.isConnected || node?.takConfig == nil) .safeAreaInset(edge: .bottom, alignment: .center) { HStack(spacing: 0) { SaveConfigButton(node: node, hasChanges: $hasChanges) { From 88bd99e3ead562cd3a02dc510dd2dabc02599f0e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:32:38 -0700 Subject: [PATCH 25/34] Gate NFC contact code behind @available(iOS 18, *) to fix App Store Connect build errors (#1659) Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/6a66f55f-2b1f-458a-b44a-21882b7ba4f6 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --- Meshtastic/Views/Settings/Settings.swift | 16 ++++++++++------ Meshtastic/Views/Settings/Tools.swift | 3 +++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 363842a2..c57f0b3d 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -406,11 +406,13 @@ struct Settings: View { Image(systemName: "gearshape") } } - NavigationLink(value: SettingsNavigationState.tools) { - Label { - Text("Tools") - } icon: { - Image(systemName: "hammer") + if #available(iOS 18, *) { + NavigationLink(value: SettingsNavigationState.tools) { + Label { + Text("Tools") + } icon: { + Image(systemName: "hammer") + } } } NavigationLink(value: SettingsNavigationState.routes) { @@ -581,7 +583,9 @@ struct Settings: View { case .firmwareUpdates: Firmware(node: node) case .tools: - Tools() + if #available(iOS 18, *) { + Tools() + } case .tak: TAKServerConfig() case .takConfig: diff --git a/Meshtastic/Views/Settings/Tools.swift b/Meshtastic/Views/Settings/Tools.swift index 897659d9..c4508deb 100644 --- a/Meshtastic/Views/Settings/Tools.swift +++ b/Meshtastic/Views/Settings/Tools.swift @@ -12,6 +12,7 @@ import CoreNFC import MeshtasticProtobufs import OSLog +@available(iOS 18, *) struct Tools: View { @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.managedObjectContext) var context @@ -69,6 +70,7 @@ struct Tools: View { } } +@available(iOS 18, *) #Preview { let context = PersistenceController.preview.container.viewContext return Tools() @@ -77,6 +79,7 @@ struct Tools: View { } #if !targetEnvironment(macCatalyst) +@available(iOS 18, *) final class NFCReader: NSObject, ObservableObject, NFCNDEFReaderSessionDelegate { private let logger = Logger(subsystem: "org.meshtastic.app", category: "NFC") From 758887b10333e999d951dd5b9021b4c839ba680b Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 15 Apr 2026 19:35:36 -0700 Subject: [PATCH 26/34] Reduce duplicate nodes --- Meshtastic/Helpers/MeshPackets.swift | 16 ++++++++++++---- Meshtastic/Persistence/UpdateCoreData.swift | 7 +------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 4ac20352..fdde3515 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -1054,10 +1054,18 @@ actor MeshPackets { /// Make a new from user if they are unknown do { let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) - let newNode = NodeInfoEntity(context: context) - newNode.id = Int64(newUser.num) - newNode.num = Int64(newUser.num) - newNode.user = newUser + // Reuse an existing NodeInfoEntity if present to avoid creating duplicates + let fetchExistingNodeRequest = NodeInfoEntity.fetchRequest() + fetchExistingNodeRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + let existingNodes = try context.fetch(fetchExistingNodeRequest) + if let existingNode = existingNodes.first { + existingNode.user = newUser + } else { + let newNode = NodeInfoEntity(context: context) + newNode.id = Int64(newUser.num) + newNode.num = Int64(newUser.num) + newNode.user = newUser + } newMessage.fromUser = newUser } catch CoreDataError.invalidInput(let message) { Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index 5a141ed3..a73651fc 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -430,18 +430,13 @@ extension MeshPackets { } } - let myInfoEntity = MyInfoEntity(context: context) - myInfoEntity.myNodeNum = Int64(packet.from) - myInfoEntity.rebootCount = 0 - newNode.myInfo = myInfoEntity do { try context.save() Logger.data.info("πŸ’Ύ [NodeInfo] Saved a NodeInfo for node number: \(packet.from.toHex(), privacy: .public)") - Logger.data.info("πŸ’Ύ [MyInfoEntity] Saved a new myInfo for node number: \(packet.from.toHex(), privacy: .public)") } catch { context.rollback() let nsError = error as NSError - Logger.data.error("πŸ’₯ [MyInfoEntity] Error Inserting New Core Data: \(nsError, privacy: .public)") + Logger.data.error("πŸ’₯ [NodeInfoEntity] Error Inserting New Core Data: \(nsError, privacy: .public)") } } else { From a2392cb069bcca0510957a2ef20f6e0f2f86401c Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 15 Apr 2026 19:48:14 -0700 Subject: [PATCH 27/34] Initialize Spec Kit --- .github/agents/speckit.analyze.agent.md | 184 ++++ .github/agents/speckit.checklist.agent.md | 295 ++++++ .github/agents/speckit.clarify.agent.md | 181 ++++ .github/agents/speckit.constitution.agent.md | 84 ++ .github/agents/speckit.implement.agent.md | 198 +++++ .github/agents/speckit.plan.agent.md | 153 ++++ .github/agents/speckit.specify.agent.md | 306 +++++++ .github/agents/speckit.tasks.agent.md | 200 +++++ .github/agents/speckit.taskstoissues.agent.md | 30 + .github/prompts/speckit.analyze.prompt.md | 3 + .github/prompts/speckit.checklist.prompt.md | 3 + .github/prompts/speckit.clarify.prompt.md | 3 + .../prompts/speckit.constitution.prompt.md | 3 + .github/prompts/speckit.implement.prompt.md | 3 + .github/prompts/speckit.plan.prompt.md | 3 + .github/prompts/speckit.specify.prompt.md | 3 + .github/prompts/speckit.tasks.prompt.md | 3 + .../prompts/speckit.taskstoissues.prompt.md | 3 + .specify/init-options.json | 12 + .specify/integration.json | 7 + .specify/integrations/copilot.manifest.json | 28 + .../copilot/scripts/update-context.ps1 | 32 + .../copilot/scripts/update-context.sh | 37 + .specify/integrations/speckit.manifest.json | 18 + .specify/memory/constitution.md | 50 ++ .specify/scripts/bash/check-prerequisites.sh | 190 ++++ .specify/scripts/bash/common.sh | 330 +++++++ .specify/scripts/bash/create-new-feature.sh | 349 ++++++++ .specify/scripts/bash/setup-plan.sh | 73 ++ .specify/scripts/bash/update-agent-context.sh | 837 ++++++++++++++++++ .specify/templates/agent-file-template.md | 28 + .specify/templates/checklist-template.md | 40 + .specify/templates/constitution-template.md | 50 ++ .specify/templates/plan-template.md | 104 +++ .specify/templates/spec-template.md | 128 +++ .specify/templates/tasks-template.md | 251 ++++++ .vscode/settings.json | 14 + 37 files changed, 4236 insertions(+) create mode 100644 .github/agents/speckit.analyze.agent.md create mode 100644 .github/agents/speckit.checklist.agent.md create mode 100644 .github/agents/speckit.clarify.agent.md create mode 100644 .github/agents/speckit.constitution.agent.md create mode 100644 .github/agents/speckit.implement.agent.md create mode 100644 .github/agents/speckit.plan.agent.md create mode 100644 .github/agents/speckit.specify.agent.md create mode 100644 .github/agents/speckit.tasks.agent.md create mode 100644 .github/agents/speckit.taskstoissues.agent.md create mode 100644 .github/prompts/speckit.analyze.prompt.md create mode 100644 .github/prompts/speckit.checklist.prompt.md create mode 100644 .github/prompts/speckit.clarify.prompt.md create mode 100644 .github/prompts/speckit.constitution.prompt.md create mode 100644 .github/prompts/speckit.implement.prompt.md create mode 100644 .github/prompts/speckit.plan.prompt.md create mode 100644 .github/prompts/speckit.specify.prompt.md create mode 100644 .github/prompts/speckit.tasks.prompt.md create mode 100644 .github/prompts/speckit.taskstoissues.prompt.md create mode 100644 .specify/init-options.json create mode 100644 .specify/integration.json create mode 100644 .specify/integrations/copilot.manifest.json create mode 100644 .specify/integrations/copilot/scripts/update-context.ps1 create mode 100755 .specify/integrations/copilot/scripts/update-context.sh create mode 100644 .specify/integrations/speckit.manifest.json create mode 100644 .specify/memory/constitution.md create mode 100755 .specify/scripts/bash/check-prerequisites.sh create mode 100755 .specify/scripts/bash/common.sh create mode 100755 .specify/scripts/bash/create-new-feature.sh create mode 100755 .specify/scripts/bash/setup-plan.sh create mode 100755 .specify/scripts/bash/update-agent-context.sh create mode 100644 .specify/templates/agent-file-template.md create mode 100644 .specify/templates/checklist-template.md create mode 100644 .specify/templates/constitution-template.md create mode 100644 .specify/templates/plan-template.md create mode 100644 .specify/templates/spec-template.md create mode 100644 .specify/templates/tasks-template.md create mode 100644 .vscode/settings.json diff --git a/.github/agents/speckit.analyze.agent.md b/.github/agents/speckit.analyze.agent.md new file mode 100644 index 00000000..0c71cf32 --- /dev/null +++ b/.github/agents/speckit.analyze.agent.md @@ -0,0 +1,184 @@ +--- +description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation. +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Goal + +Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`. + +## Operating Constraints + +**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually). + +**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasksβ€”not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`. + +## Execution Steps + +### 1. Initialize Analysis Context + +Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths: + +- SPEC = FEATURE_DIR/spec.md +- PLAN = FEATURE_DIR/plan.md +- TASKS = FEATURE_DIR/tasks.md + +Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command). +For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). + +### 2. Load Artifacts (Progressive Disclosure) + +Load only the minimal necessary context from each artifact: + +**From spec.md:** + +- Overview/Context +- Functional Requirements +- Success Criteria (measurable outcomes β€” e.g., performance, security, availability, user success, business impact) +- User Stories +- Edge Cases (if present) + +**From plan.md:** + +- Architecture/stack choices +- Data Model references +- Phases +- Technical constraints + +**From tasks.md:** + +- Task IDs +- Descriptions +- Phase grouping +- Parallel markers [P] +- Referenced file paths + +**From constitution:** + +- Load `.specify/memory/constitution.md` for principle validation + +### 3. Build Semantic Models + +Create internal representations (do not include raw artifacts in output): + +- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" β†’ `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%"). +- **User story/action inventory**: Discrete user actions with acceptance criteria +- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases) +- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements + +### 4. Detection Passes (Token-Efficient Analysis) + +Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary. + +#### A. Duplication Detection + +- Identify near-duplicate requirements +- Mark lower-quality phrasing for consolidation + +#### B. Ambiguity Detection + +- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria +- Flag unresolved placeholders (TODO, TKTK, ???, ``, etc.) + +#### C. Underspecification + +- Requirements with verbs but missing object or measurable outcome +- User stories missing acceptance criteria alignment +- Tasks referencing files or components not defined in spec/plan + +#### D. Constitution Alignment + +- Any requirement or plan element conflicting with a MUST principle +- Missing mandated sections or quality gates from constitution + +#### E. Coverage Gaps + +- Requirements with zero associated tasks +- Tasks with no mapped requirement/story +- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks + +#### F. Inconsistency + +- Terminology drift (same concept named differently across files) +- Data entities referenced in plan but absent in spec (or vice versa) +- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note) +- Conflicting requirements (e.g., one requires Next.js while other specifies Vue) + +### 5. Severity Assignment + +Use this heuristic to prioritize findings: + +- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality +- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion +- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case +- **LOW**: Style/wording improvements, minor redundancy not affecting execution order + +### 6. Produce Compact Analysis Report + +Output a Markdown report (no file writes) with the following structure: + +## Specification Analysis Report + +| ID | Category | Severity | Location(s) | Summary | Recommendation | +|----|----------|----------|-------------|---------|----------------| +| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version | + +(Add one row per finding; generate stable IDs prefixed by category initial.) + +**Coverage Summary Table:** + +| Requirement Key | Has Task? | Task IDs | Notes | +|-----------------|-----------|----------|-------| + +**Constitution Alignment Issues:** (if any) + +**Unmapped Tasks:** (if any) + +**Metrics:** + +- Total Requirements +- Total Tasks +- Coverage % (requirements with >=1 task) +- Ambiguity Count +- Duplication Count +- Critical Issues Count + +### 7. Provide Next Actions + +At end of report, output a concise Next Actions block: + +- If CRITICAL issues exist: Recommend resolving before `/speckit.implement` +- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions +- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'" + +### 8. Offer Remediation + +Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.) + +## Operating Principles + +### Context Efficiency + +- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation +- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis +- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow +- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts + +### Analysis Guidelines + +- **NEVER modify files** (this is read-only analysis) +- **NEVER hallucinate missing sections** (if absent, report them accurately) +- **Prioritize constitution violations** (these are always CRITICAL) +- **Use examples over exhaustive rules** (cite specific instances, not generic patterns) +- **Report zero issues gracefully** (emit success report with coverage statistics) + +## Context + +$ARGUMENTS diff --git a/.github/agents/speckit.checklist.agent.md b/.github/agents/speckit.checklist.agent.md new file mode 100644 index 00000000..b7624e22 --- /dev/null +++ b/.github/agents/speckit.checklist.agent.md @@ -0,0 +1,295 @@ +--- +description: Generate a custom checklist for the current feature based on user requirements. +--- + +## Checklist Purpose: "Unit Tests for English" + +**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain. + +**NOT for verification/testing**: + +- ❌ NOT "Verify the button clicks correctly" +- ❌ NOT "Test error handling works" +- ❌ NOT "Confirm the API returns 200" +- ❌ NOT checking if code/implementation matches the spec + +**FOR requirements quality validation**: + +- βœ… "Are visual hierarchy requirements defined for all card types?" (completeness) +- βœ… "Is 'prominent display' quantified with specific sizing/positioning?" (clarity) +- βœ… "Are hover state requirements consistent across all interactive elements?" (consistency) +- βœ… "Are accessibility requirements defined for keyboard navigation?" (coverage) +- βœ… "Does the spec define what happens when logo image fails to load?" (edge cases) + +**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works. + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Execution Steps + +1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list. + - All file paths must be absolute. + - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). + +2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST: + - Be generated from the user's phrasing + extracted signals from spec/plan/tasks + - Only ask about information that materially changes checklist content + - Be skipped individually if already unambiguous in `$ARGUMENTS` + - Prefer precision over breadth + + Generation algorithm: + 1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts"). + 2. Cluster signals into candidate focus areas (max 4) ranked by relevance. + 3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit. + 4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria. + 5. Formulate questions chosen from these archetypes: + - Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?") + - Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?") + - Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?") + - Audience framing (e.g., "Will this be used by the author only or peers during PR review?") + - Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?") + - Scenario class gap (e.g., "No recovery flows detectedβ€”are rollback / partial failure paths in scope?") + + Question formatting rules: + - If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters + - Limit to A–E options maximum; omit table if a free-form answer is clearer + - Never ask the user to restate what they already said + - Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope." + + Defaults when interaction impossible: + - Depth: Standard + - Audience: Reviewer (PR) if code-related; Author otherwise + - Focus: Top 2 relevance clusters + + Output the questions (label Q1/Q2/Q3). After answers: if β‰₯2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more. + +3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers: + - Derive checklist theme (e.g., security, review, deploy, ux) + - Consolidate explicit must-have items mentioned by user + - Map focus selections to category scaffolding + - Infer any missing context from spec/plan/tasks (do NOT hallucinate) + +4. **Load feature context**: Read from FEATURE_DIR: + - spec.md: Feature requirements and scope + - plan.md (if exists): Technical details, dependencies + - tasks.md (if exists): Implementation tasks + + **Context Loading Strategy**: + - Load only necessary portions relevant to active focus areas (avoid full-file dumping) + - Prefer summarizing long sections into concise scenario/requirement bullets + - Use progressive disclosure: add follow-on retrieval only if gaps detected + - If source docs are large, generate interim summary items instead of embedding raw text + +5. **Generate checklist** - Create "Unit Tests for Requirements": + - Create `FEATURE_DIR/checklists/` directory if it doesn't exist + - Generate unique checklist filename: + - Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`) + - Format: `[domain].md` + - File handling behavior: + - If file does NOT exist: Create new file and number items starting from CHK001 + - If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016) + - Never delete or replace existing checklist content - always preserve and append + + **CORE PRINCIPLE - Test the Requirements, Not the Implementation**: + Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for: + - **Completeness**: Are all necessary requirements present? + - **Clarity**: Are requirements unambiguous and specific? + - **Consistency**: Do requirements align with each other? + - **Measurability**: Can requirements be objectively verified? + - **Coverage**: Are all scenarios/edge cases addressed? + + **Category Structure** - Group items by requirement quality dimensions: + - **Requirement Completeness** (Are all necessary requirements documented?) + - **Requirement Clarity** (Are requirements specific and unambiguous?) + - **Requirement Consistency** (Do requirements align without conflicts?) + - **Acceptance Criteria Quality** (Are success criteria measurable?) + - **Scenario Coverage** (Are all flows/cases addressed?) + - **Edge Case Coverage** (Are boundary conditions defined?) + - **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?) + - **Dependencies & Assumptions** (Are they documented and validated?) + - **Ambiguities & Conflicts** (What needs clarification?) + + **HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**: + + ❌ **WRONG** (Testing implementation): + - "Verify landing page displays 3 episode cards" + - "Test hover states work on desktop" + - "Confirm logo click navigates home" + + βœ… **CORRECT** (Testing requirements quality): + - "Are the exact number and layout of featured episodes specified?" [Completeness] + - "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity] + - "Are hover state requirements consistent across all interactive elements?" [Consistency] + - "Are keyboard navigation requirements defined for all interactive UI?" [Coverage] + - "Is the fallback behavior specified when logo image fails to load?" [Edge Cases] + - "Are loading states defined for asynchronous episode data?" [Completeness] + - "Does the spec define visual hierarchy for competing UI elements?" [Clarity] + + **ITEM STRUCTURE**: + Each item should follow this pattern: + - Question format asking about requirement quality + - Focus on what's WRITTEN (or not written) in the spec/plan + - Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.] + - Reference spec section `[Spec Β§X.Y]` when checking existing requirements + - Use `[Gap]` marker when checking for missing requirements + + **EXAMPLES BY QUALITY DIMENSION**: + + Completeness: + - "Are error handling requirements defined for all API failure modes? [Gap]" + - "Are accessibility requirements specified for all interactive elements? [Completeness]" + - "Are mobile breakpoint requirements defined for responsive layouts? [Gap]" + + Clarity: + - "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec Β§NFR-2]" + - "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec Β§FR-5]" + - "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec Β§FR-4]" + + Consistency: + - "Do navigation requirements align across all pages? [Consistency, Spec Β§FR-10]" + - "Are card component requirements consistent between landing and detail pages? [Consistency]" + + Coverage: + - "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]" + - "Are concurrent user interaction scenarios addressed? [Coverage, Gap]" + - "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]" + + Measurability: + - "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec Β§FR-1]" + - "Can 'balanced visual weight' be objectively verified? [Measurability, Spec Β§FR-2]" + + **Scenario Classification & Coverage** (Requirements Quality Focus): + - Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios + - For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?" + - If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]" + - Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]" + + **Traceability Requirements**: + - MINIMUM: β‰₯80% of items MUST include at least one traceability reference + - Each item should reference: spec section `[Spec Β§X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]` + - If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]" + + **Surface & Resolve Issues** (Requirements Quality Problems): + Ask questions about the requirements themselves: + - Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec Β§NFR-1]" + - Conflicts: "Do navigation requirements conflict between Β§FR-10 and Β§FR-10a? [Conflict]" + - Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]" + - Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]" + - Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]" + + **Content Consolidation**: + - Soft cap: If raw candidate items > 40, prioritize by risk/impact + - Merge near-duplicates checking the same requirement aspect + - If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]" + + **🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test: + - ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior + - ❌ References to code execution, user actions, system behavior + - ❌ "Displays correctly", "works properly", "functions as expected" + - ❌ "Click", "navigate", "render", "load", "execute" + - ❌ Test cases, test plans, QA procedures + - ❌ Implementation details (frameworks, APIs, algorithms) + + **βœ… REQUIRED PATTERNS** - These test requirements quality: + - βœ… "Are [requirement type] defined/specified/documented for [scenario]?" + - βœ… "Is [vague term] quantified/clarified with specific criteria?" + - βœ… "Are requirements consistent between [section A] and [section B]?" + - βœ… "Can [requirement] be objectively measured/verified?" + - βœ… "Are [edge cases/scenarios] addressed in requirements?" + - βœ… "Does the spec define [missing aspect]?" + +6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### ` lines with globally incrementing IDs starting at CHK001. + +7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize: + - Focus areas selected + - Depth level + - Actor/timing + - Any explicit user-specified must-have items incorporated + +**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows: + +- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`) +- Simple, memorable filenames that indicate checklist purpose +- Easy identification and navigation in the `checklists/` folder + +To avoid clutter, use descriptive types and clean up obsolete checklists when done. + +## Example Checklist Types & Sample Items + +**UX Requirements Quality:** `ux.md` + +Sample items (testing the requirements, NOT the implementation): + +- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec Β§FR-1]" +- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec Β§FR-1]" +- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]" +- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]" +- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]" +- "Can 'prominent display' be objectively measured? [Measurability, Spec Β§FR-4]" + +**API Requirements Quality:** `api.md` + +Sample items: + +- "Are error response formats specified for all failure scenarios? [Completeness]" +- "Are rate limiting requirements quantified with specific thresholds? [Clarity]" +- "Are authentication requirements consistent across all endpoints? [Consistency]" +- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]" +- "Is versioning strategy documented in requirements? [Gap]" + +**Performance Requirements Quality:** `performance.md` + +Sample items: + +- "Are performance requirements quantified with specific metrics? [Clarity]" +- "Are performance targets defined for all critical user journeys? [Coverage]" +- "Are performance requirements under different load conditions specified? [Completeness]" +- "Can performance requirements be objectively measured? [Measurability]" +- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]" + +**Security Requirements Quality:** `security.md` + +Sample items: + +- "Are authentication requirements specified for all protected resources? [Coverage]" +- "Are data protection requirements defined for sensitive information? [Completeness]" +- "Is the threat model documented and requirements aligned to it? [Traceability]" +- "Are security requirements consistent with compliance obligations? [Consistency]" +- "Are security failure/breach response requirements defined? [Gap, Exception Flow]" + +## Anti-Examples: What NOT To Do + +**❌ WRONG - These test implementation, not requirements:** + +```markdown +- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec Β§FR-001] +- [ ] CHK002 - Test hover states work correctly on desktop [Spec Β§FR-003] +- [ ] CHK003 - Confirm logo click navigates to home page [Spec Β§FR-010] +- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec Β§FR-005] +``` + +**βœ… CORRECT - These test requirements quality:** + +```markdown +- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec Β§FR-001] +- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec Β§FR-003] +- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec Β§FR-010] +- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec Β§FR-005] +- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap] +- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec Β§FR-001] +``` + +**Key Differences:** + +- Wrong: Tests if the system works correctly +- Correct: Tests if the requirements are written correctly +- Wrong: Verification of behavior +- Correct: Validation of requirement quality +- Wrong: "Does it do X?" +- Correct: "Is X clearly specified?" diff --git a/.github/agents/speckit.clarify.agent.md b/.github/agents/speckit.clarify.agent.md new file mode 100644 index 00000000..3f4376a4 --- /dev/null +++ b/.github/agents/speckit.clarify.agent.md @@ -0,0 +1,181 @@ +--- +description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec. +handoffs: + - label: Build Technical Plan + agent: speckit.plan + prompt: Create a plan for the spec. I am building with... +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Outline + +Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file. + +Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases. + +Execution steps: + +1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields: + - `FEATURE_DIR` + - `FEATURE_SPEC` + - (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.) + - If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment. + - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). + +2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked). + + Functional Scope & Behavior: + - Core user goals & success criteria + - Explicit out-of-scope declarations + - User roles / personas differentiation + + Domain & Data Model: + - Entities, attributes, relationships + - Identity & uniqueness rules + - Lifecycle/state transitions + - Data volume / scale assumptions + + Interaction & UX Flow: + - Critical user journeys / sequences + - Error/empty/loading states + - Accessibility or localization notes + + Non-Functional Quality Attributes: + - Performance (latency, throughput targets) + - Scalability (horizontal/vertical, limits) + - Reliability & availability (uptime, recovery expectations) + - Observability (logging, metrics, tracing signals) + - Security & privacy (authN/Z, data protection, threat assumptions) + - Compliance / regulatory constraints (if any) + + Integration & External Dependencies: + - External services/APIs and failure modes + - Data import/export formats + - Protocol/versioning assumptions + + Edge Cases & Failure Handling: + - Negative scenarios + - Rate limiting / throttling + - Conflict resolution (e.g., concurrent edits) + + Constraints & Tradeoffs: + - Technical constraints (language, storage, hosting) + - Explicit tradeoffs or rejected alternatives + + Terminology & Consistency: + - Canonical glossary terms + - Avoided synonyms / deprecated terms + + Completion Signals: + - Acceptance criteria testability + - Measurable Definition of Done style indicators + + Misc / Placeholders: + - TODO markers / unresolved decisions + - Ambiguous adjectives ("robust", "intuitive") lacking quantification + + For each category with Partial or Missing status, add a candidate question opportunity unless: + - Clarification would not materially change implementation or validation strategy + - Information is better deferred to planning phase (note internally) + +3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints: + - Maximum of 5 total questions across the whole session. + - Each question must be answerable with EITHER: + - A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR + - A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words"). + - Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation. + - Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved. + - Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness). + - Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests. + - If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic. + +4. Sequential questioning loop (interactive): + - Present EXACTLY ONE question at a time. + - For multiple‑choice questions: + - **Analyze all options** and determine the **most suitable option** based on: + - Best practices for the project type + - Common patterns in similar implementations + - Risk reduction (security, performance, maintainability) + - Alignment with any explicit project goals or constraints visible in the spec + - Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice). + - Format as: `**Recommended:** Option [X] - ` + - Then render all options as a Markdown table: + + | Option | Description | + |--------|-------------| + | A |